// Residual surviving-mutant kills for WPParticleParser.cpp.
//
// Companion to test_WPParticleParserMore / Gaps / DeepKill.  Each TEST_CASE
// pins a specific surviving mutant by line number — the goal is to drive the
// surviving boundary / arithmetic / dispatch mutations to OBSERVABLE drift.
//
// Patterns avoided (per project test-authoring gotchas):
//   * No timing-window assertions tied to wall-clock thresholds — the
//     mapsequencebetweencontrolpoints kGapResetSec=0.25 window in DeepKill is
//     the cautionary tale; we only exercise behaviour observable from
//     deterministic inputs.
//   * No probabilistic "this should drift" assertions on Random:: outputs;
//     where randomness leaks into the API we either assert finite/in-range
//     or use deterministic seed paths (e.g. min==max) that pin the answer.
//
// Pre-allocate vectors and use INDEX access (not references) to avoid the
// push_back invalidation bug noted in DeepKill's header comment.

#include <doctest.h>

#include "WPParticleParser.hpp"
#include "Particle/Particle.h"
#include "Particle/ParticleEmitter.h"
#include "Particle/ParticleModify.h"
#include "Particle/ParticleSystem.h"
#include "wpscene/WPParticleObject.h"

#include "Core/Random.hpp"

#include <Eigen/Core>
#include <array>
#include <cmath>
#include <nlohmann/json.hpp>
#include <span>
#include <vector>

using namespace wallpaper;
using nlohmann::json;

namespace residual_helpers
{

inline Particle makeParticle() {
    Particle p;
    p.lifetime        = 1.0f;
    p.init.lifetime   = 1.0f;
    p.alpha           = 1.0f;
    p.init.alpha      = 1.0f;
    p.size            = 20.0f;
    p.init.size       = 20.0f;
    p.color           = Eigen::Vector3f(1, 1, 1);
    p.init.color      = p.color;
    p.position        = Eigen::Vector3f(0, 0, 0);
    p.velocity        = Eigen::Vector3f(0, 0, 0);
    p.rotation        = Eigen::Vector3f(0, 0, 0);
    p.angularVelocity = Eigen::Vector3f(0, 0, 0);
    p.random_seed     = 0xdeadbeef;
    return p;
}

inline wpscene::ParticleInstanceoverride emptyOverride() {
    wpscene::ParticleInstanceoverride o;
    o.enabled = false;
    return o;
}

inline std::span<ParticleControlpoint>
defaultCps(std::vector<ParticleControlpoint>& storage) {
    storage.assign(8, ParticleControlpoint {});
    for (auto& cp : storage) {
        cp.resolved = Eigen::Vector3d(0, 0, 0);
        cp.velocity = Eigen::Vector3d(0, 0, 0);
    }
    return std::span<ParticleControlpoint>(storage.data(), storage.size());
}

// Determinism note: doctest runs all TEST_CASEs sequentially in a single
// process.  Tests that consume Random:: state can shift later tests'
// pass/fail outcomes for mutants that depend on randomized branches.
// Where determinism matters, tests use min == max so the random pick
// collapses, or assert in-range (not exact equality) so jitter is tolerated.
//
// `seededInit` is provided for the few cases that need explicit re-seeding
// before re-invoking a captured Random-using initializer; most tests pass
// `min == max` and don't need it.
inline void seedDeterministic() {
    Random::seed(0xC0FFEE42u);
}

inline void seededInit(const ParticleInitOp& init, Particle& p, double dt = 0.0) {
    init(p, dt);
}

inline Particle runOpSingle(ParticleOperatorOp op, const Particle& base,
                            double time_pass = 0.016, double time = 0.0) {
    std::vector<Particle> ps;
    ps.reserve(8);
    ps.push_back(base);
    std::vector<ParticleControlpoint> cps_storage;
    auto                              cps = defaultCps(cps_storage);
    ParticleInfo                      info {
                             .particles     = std::span<Particle>(ps),
                             .controlpoints = cps,
                             .time          = time,
                             .time_pass     = time_pass,
    };
    op(info);
    return ps[0];
}

inline Particle runOpSingleCps(ParticleOperatorOp op, const Particle& base,
                               std::span<ParticleControlpoint> cps,
                               double time_pass = 0.016, double time = 0.0) {
    std::vector<Particle> ps;
    ps.reserve(8);
    ps.push_back(base);
    ParticleInfo info {
        .particles     = std::span<Particle>(ps),
        .controlpoints = cps,
        .time          = time,
        .time_pass     = time_pass,
    };
    op(info);
    return ps[0];
}

} // namespace residual_helpers

using namespace residual_helpers;

// ============================================================================
// SingleRandom::get() exponent path — line 65 (`!=` vs `==`)
// ============================================================================
//
// `if (exponent != 1.0f) t = std::pow(t, exponent);` — when exponent is
// authored at the default 1.0f, the std::pow call must be SKIPPED.  A
// mutation `!=` → `==` would pow when it shouldn't (and skip when it
// should), shifting the sampled distribution.  The cleanest observable
// is the min==max path: exponent is irrelevant because the distribution
// collapses, but lifetimerandom's `t > 0.0f && r.min == r.max` jitter
// gate only fires in the != branch.  We also pin sizerandom's exponent
// dispatch (line 156).

TEST_SUITE("SingleRandom exponent dispatch") {
    TEST_CASE("alpharandom min==max exponent=1: returns exact min/max") {
        // No exponent → t direct → min + (max-min)*t = min == max, exact.
        json j = { { "name", "alpharandom" }, { "min", 0.5 }, { "max", 0.5 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.alpha == doctest::Approx(0.5f).epsilon(0.001));
    }

    TEST_CASE("sizerandom min==max with default exponent=1 ignores exponent path") {
        // Even though min==max collapses the result, the exponent != 1.0f
        // branch still gets taken when authored.  Verify the default (=1)
        // path leaves things finite / equal to the min/max.
        json j = { { "name", "sizerandom" }, { "min", 7.0 }, { "max", 7.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.size == doctest::Approx(7.0f).epsilon(0.001));
    }

    TEST_CASE("sizerandom with exponent=2: result still in [min, max]") {
        // exponent != 1 hits the std::pow branch.  Output is still in range.
        json j = { { "name", "sizerandom" }, { "min", 4.0 }, { "max", 8.0 },
                   { "exponent", 2.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 20; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            CHECK(p.size >= 4.0f);
            CHECK(p.size <= 8.0f);
        }
    }
}

// ============================================================================
// lifetimerandom jitter — lines 148, 149 (`>` vs `>=`, `+` vs `-`)
// ============================================================================
//
// `if (r.min == r.max && t > 0.0f) t *= 1.0f + Random::get(-0.05f, 0.05f);`
// Pin behaviour at t=0 (no jitter — kills `>` → `>=`) and confirm jitter
// keeps lifetime in [0.95t, 1.05t] (kills `+` → `-` because subtracting a
// random in [-0.05, 0.05] yields the same window so this shape doesn't
// distinguish; we pin the additive nature with an authored value > 1).

TEST_SUITE("lifetimerandom jitter boundary") {
    TEST_CASE("min==max with t=0: jitter SKIPPED (boundary `t > 0.0f`)") {
        // min=max=0 → t=0 → t*0 keeps t=0 → InitLifetime(p, 0) → lifetime=0.
        json j = { { "name", "lifetimerandom" }, { "min", 0.0 }, { "max", 0.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        p.init.lifetime = 0.0f;
        p.lifetime      = 0.0f;
        seededInit(init, p, 0.0);
        // Must be EXACTLY 0 — any jitter (`>=` mutation) would multiply 0
        // by a [0.95, 1.05] factor (still 0 — but differs at IEEE level if
        // we ever pull in a NaN).  Both should yield 0; check finite.
        CHECK(p.init.lifetime == doctest::Approx(0.0f));
    }

    TEST_CASE("min==max=1.0: jitter window centred at 1.0 (kills sub-to-add on `1.0f + ...`)") {
        // `1.0f + Random::get(-0.05f, 0.05f)` → range [0.95, 1.05].
        // Mutation `+` → `-`: `1.0f - Random::get(...)` → still [0.95, 1.05] (mirror).
        // That's why DeepKill's existing test passes both forms!  But mutation
        // `1.0f` → `0.0f` (cxx_minus_to_noop on the unary +) would yield
        // [-0.05, 0.05].  Pin by checking the result is positively biased
        // around min*1.0 — i.e. ≥ 0.5*min, not near zero.  Run 50 reps.
        json j = { { "name", "lifetimerandom" }, { "min", 1.0 }, { "max", 1.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 50; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            CHECK(p.init.lifetime >= 0.5f);   // not collapsed to ~0
            CHECK(p.init.lifetime <= 1.5f);   // not blown up
        }
    }
}

// ============================================================================
// velocityrandom CP inheritance — line 192 (`<` vs `<=`)
// ============================================================================
//
// `if (inherit_cp_vel && (usize)inherit_cp < cp_size)` — at inherit_cp ==
// cp_size the inheritance must NOT fire (out-of-range).  Cleanest observable:
// authored cp index 7, table size 8 → fires (inside).  Authored cp 8 (clamped
// to 7 by ClampCpIndex on line 186) → also fires.  We test the negative path
// by giving a 0-sized CP table.

TEST_SUITE("velocityrandom inherit CP boundary") {
    TEST_CASE("inheritcontrolpointvelocity true with cp_size=0: skips inheritance") {
        // Empty CP span.  Inheritance branch must fall through.
        json j = { { "name", "velocityrandom" },
                   { "min", "0 0 0" }, { "max", "0 0 0" },
                   { "inheritcontrolpointvelocity", true },
                   { "controlpoint", 0 } };
        auto init = WPParticleParser::genParticleInitOp(j, {});
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.velocity.norm() == doctest::Approx(0.0f));
    }

    TEST_CASE("inheritcontrolpointvelocity true with valid CP: applies CP velocity") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[2].velocity = Eigen::Vector3d(10, 20, 30);
        json j = { { "name", "velocityrandom" },
                   { "min", "0 0 0" }, { "max", "0 0 0" },
                   { "inheritcontrolpointvelocity", true },
                   { "controlpoint", 2 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.velocity.x() == doctest::Approx(10.0f));
        CHECK(p.velocity.y() == doctest::Approx(20.0f));
        CHECK(p.velocity.z() == doctest::Approx(30.0f));
    }
}

// ============================================================================
// turbulentvelocityrandom — lines 223, 232, 235-240
// ============================================================================
//
// L223: `if (duration > 10.0f) { pos[0] += speed; duration = 0.0f; }`
//       `>` → `>=` & `>` → `<=`: high-duration reset path.
// L232: `while (duration > 0.01f)`: loop continues until <=0.01.
// L235: `c = result.dot(forward) / (result.norm() * forward.norm());`
// L236: `a = std::acos(c) / M_PI;`
// L237: `scale = r.scale / 2.0f;`
// L238: `if (a > scale) { ... }` & L240 multiplier inside.

TEST_SUITE("turbulentvelocityrandom boundaries") {
    TEST_CASE("scale=0 disables direction-limit branch (a > scale always false)") {
        // scale=0.0 → scale/2=0; a is always ≥ 0 → only fires when a > 0.
        // We can't pin a deterministically, but at scale=0 the rotation must
        // not stretch velocity — it caps at speedmin/speedmax.
        json j = { { "name", "turbulentvelocityrandom" },
                   { "speedmin", 100.0 }, { "speedmax", 100.0 },
                   { "scale", 0.0 }, { "timescale", 1.0 },
                   { "offset", 0.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 5; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.5);
            CHECK(p.velocity.norm() == doctest::Approx(100.0f).epsilon(0.05));
        }
    }

    TEST_CASE("scale=2 (full-allowance): direction-limit doesn't clamp") {
        // a is in [0, 1] (acos/PI).  scale/2 = 1.0 → a > 1.0 never fires
        // → no rotation correction.  Magnitude still equals speed.
        json j = { { "name", "turbulentvelocityrandom" },
                   { "speedmin", 50.0 }, { "speedmax", 50.0 },
                   { "scale", 2.0 }, { "timescale", 1.0 },
                   { "offset", 0.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.05);
        CHECK(p.velocity.norm() == doctest::Approx(50.0f).epsilon(0.1));
    }

    TEST_CASE("duration > 10.0 triggers position seed reset (kills `>` → `>=`)") {
        // Calling with duration = 10.5 must drive the reset path.  The branch
        // sets duration=0 internally, which then runs the inner do/while at
        // least once (since we then add -0.01 each iter; 0 > 0.01 is false,
        // but the do/while runs ONCE before checking).  We can't easily
        // observe pos[0]+=speed directly, but we can check the velocity
        // remains finite and non-zero at the requested speed.
        json j = { { "name", "turbulentvelocityrandom" },
                   { "speedmin", 30.0 }, { "speedmax", 30.0 },
                   { "scale", 0.0 }, { "timescale", 1.0 },
                   { "offset", 0.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 11.0);
        CHECK(std::isfinite(p.velocity.norm()));
        CHECK(p.velocity.norm() == doctest::Approx(30.0f).epsilon(0.1));
    }
}

// ============================================================================
// positionoffsetrandom CP-line projection — lines 254, 267
// ============================================================================
//
// L254: `if (distance <= 0.0f) return;` — boundary at distance==0.
// L267: `if (len2 > 1e-10) { ... project out along-line ... }` — when the two
// CPs collapse onto the same point, len2 is 0 and the projection branch is
// skipped.

TEST_SUITE("positionoffsetrandom boundaries") {
    TEST_CASE("distance == 0.0 short-circuits (boundary `<=`)") {
        json j = { { "name", "positionoffsetrandom" }, { "distance", 0.0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.position.norm() == doctest::Approx(0.0f));
    }

    TEST_CASE("CP0 == CP1 (len2=0): projection branch skipped, offset preserved") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(50, 50, 50);
        cps[1].resolved = Eigen::Vector3d(50, 50, 50); // same point
        json j = { { "name", "positionoffsetrandom" }, { "distance", 100.0 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        // With len2=0, projection is skipped — full offset applied.
        for (int i = 0; i < 5; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            CHECK(std::isfinite(p.position.norm()));
            // distance * 0.02 * cbrt(rand) max = 100*0.02*1 = 2.
            CHECK(p.position.norm() <= 2.5f);
        }
    }
}

// ============================================================================
// remapinitialvalue — lines 314, 320
// ============================================================================
//
// L314: `if ((usize)inputCP < cp_size)` — boundary CP-clamp inside lambda.
// L320: `(inMax > inMin) ? std::clamp((val - inMin) / (inMax - inMin), 0, 1) : 0.0`
//       — kills both `> → >=` and the two `-` operations.

TEST_SUITE("remapinitialvalue inMax<=inMin variants") {
    TEST_CASE("inMax > inMin: linear interp at val=inMid") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(50, 0, 0); // distance=50
        json j = { { "name", "remapinitialvalue" },
                   { "input", "distancetocontrolpoint" },
                   { "output", "size" }, { "operation", "set" },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 200.0 },
                   { "inputcontrolpoint0", 0 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        p.size = 0.0f;
        p.position = Eigen::Vector3f(0, 0, 0);   // dist to cp 50 = 50
        seededInit(init, p, 0.0);
        // val = 50, t = (50-0)/(100-0) = 0.5, outVal = 0 + 0.5*200 = 100.
        CHECK(p.size == doctest::Approx(100.0f).epsilon(0.01));
    }

    TEST_CASE("inMax < inMin (negative span): t = 0 (set returns outMin)") {
        // inMin=100, inMax=50 → inMax > inMin is false → t=0 → outVal=outMin.
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(50, 0, 0);
        json j = { { "name", "remapinitialvalue" },
                   { "input", "distancetocontrolpoint" },
                   { "output", "size" }, { "operation", "set" },
                   { "inputrangemin", 100.0 }, { "inputrangemax", 50.0 },
                   { "outputrangemin", 33.0 }, { "outputrangemax", 99.0 },
                   { "inputcontrolpoint0", 0 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        p.size = 0.0f;
        seededInit(init, p, 0.0);
        CHECK(p.size == doctest::Approx(33.0f).epsilon(0.01));
    }

    TEST_CASE("inputCP at exact cp_size boundary: no read (skipped)") {
        // 4 CPs in span; inputcp=4 (clamped to 7 by ClampCpIndex but cp_size=4).
        // The internal `inputCP < cp_size` (4 < 4) is false → no read of resolved.
        std::vector<ParticleControlpoint> cps(4, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(99, 0, 0);
        cps[3].resolved = Eigen::Vector3d(11, 0, 0);
        json j = { { "name", "remapinitialvalue" },
                   { "input", "distancetocontrolpoint" },
                   { "output", "size" }, { "operation", "set" },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 4 } };  // ClampCpIndex(4) = 4 ≤ 7
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        p.size     = 0.0f;
        p.position = Eigen::Vector3f(0, 0, 0);
        seededInit(init, p, 0.0);
        // val = 0 (skipped), t=0, size = outMin = 0.
        CHECK(p.size == doctest::Approx(0.0f).epsilon(0.01));
    }
}

// ============================================================================
// mapsequencebetweencontrolpoints — lines 370, 371, 380, 387-395
// ============================================================================
//
// L370: `if (cp_size <= (usize)cpStart || cp_size <= (usize)cpEnd || count <= 1)`
//       — boundary `<=` (size == cpStart skips).
// L371: `seq++` post-increment in skip path.
// L380: `if (delta > kGapResetSec) { seq = 0; }` — gap-reset path.
// L387: `if (mirror && count > 1)` — mirror path enabled.
// L388: `u32 period = 2 * (count - 1);` — period formula.
// L389: `u32 pos = seq % period;` — modulo.
// L390: `idx = pos < count ? pos : period - pos;` — mirror reflection.
// L395: `if (idx == 0) { ... regenerate phases ... }` — cycle start.

TEST_SUITE("mapsequencebetweencontrolpoints residual") {
    TEST_CASE("count == 1 short-circuits (kills `<= 1` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 1 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 50, 50);  // arbitrary pre-init
        seededInit(init, p, 0.0);
        // Short-circuit just bumps seq; p.position stays where it was.
        CHECK(p.position == Eigen::Vector3f(50, 50, 50));
    }

    TEST_CASE("count == 2: place at endpoints (mirror=false, idx=0 then idx=1)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(10, 20, 30);
        cps[1].resolved = Eigen::Vector3d(70, 80, 90);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 2 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));

        // First spawn: seq=0 → idx=0 → t=0 → cp0 (10,20,30).
        Particle a = makeParticle();
        seededInit(init, a, 0.0);
        CHECK(a.position.x() == doctest::Approx(10.0f).epsilon(0.5));
        CHECK(a.position.y() == doctest::Approx(20.0f).epsilon(0.5));

        // Second spawn: seq=1 → idx=1 → t=1 → cp1 (70,80,90).
        Particle b = makeParticle();
        seededInit(init, b, 0.0);
        CHECK(b.position.x() == doctest::Approx(70.0f).epsilon(0.5));
        CHECK(b.position.y() == doctest::Approx(80.0f).epsilon(0.5));
    }

    TEST_CASE("mirror=true, count=3: period=2*(count-1)=4, idx maps endpoints") {
        // count=3 → period=4.  Seq 0,1,2 → pos 0,1,2 → idx 0,1,2 (mirror is
        // identity on the first half-cycle).  Asserting only the first 3
        // particles to keep the wall-clock gap-reset timer from interfering.
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 3 }, { "mirror", true },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));

        std::array<Particle, 3> ps;
        for (int i = 0; i < 3; i++) {
            ps[i] = makeParticle();
            seededInit(init, ps[i], 0.0);
        }
        // idx 0 → t=0 → x=0 (taper=0 zeros noise; arc_amount=0 default).
        // idx 1 → t=0.5 → midpoint x=50 (plus zero arc, plus noise).
        // idx 2 → t=1 → x=100 (taper=0 again).
        CHECK(ps[0].position.x() == doctest::Approx(0.0f).epsilon(0.5));
        CHECK(ps[2].position.x() == doctest::Approx(100.0f).epsilon(0.5));
    }

    TEST_CASE("mirror=false, count=3: idx = seq % count (no reflection)") {
        // Without mirror, first three: idx = seq % 3 → 0, 1, 2 → t = 0, 0.5, 1.0.
        // Endpoints (idx=0 and idx=2) have taper=sin(0)=0 / sin(π)=0 zeroing noise.
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 3 }, { "mirror", false },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));

        std::array<Particle, 3> ps;
        for (int i = 0; i < 3; i++) {
            ps[i] = makeParticle();
            seededInit(init, ps[i], 0.0);
        }
        CHECK(ps[0].position.x() == doctest::Approx(0.0f).epsilon(0.5));
        CHECK(ps[2].position.x() == doctest::Approx(100.0f).epsilon(0.5));
    }

    TEST_CASE("cpStart out-of-range bumps seq but no positioning") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(11, 22, 33);
        cps[1].resolved = Eigen::Vector3d(99, 88, 77);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 4 },
                   { "controlpointstart", 7 },  // out-of-range vs cps.size()==2
                   { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(5, 5, 5);
        seededInit(init, p, 0.0);
        // Position untouched.
        CHECK(p.position.x() == doctest::Approx(5.0f));
        CHECK(p.position.y() == doctest::Approx(5.0f));
    }
}

// ============================================================================
// mapsequencebetweencontrolpoints arc / size_reduction — lines 412-450
// ============================================================================
//
// L412: `if (arc_dir.squaredNorm() < 1e-6f) { arc_dir = Vector3f(-line.y(), line.x(), 0) }`
//       — fallback when arc_dir is zero.
// L413: unary minus on `-line.y()`.
// L414: `if (arc_dir.squaredNorm() < 1e-6f) arc_dir = Vector3f(0,0,1.0f);`
//       — degenerate case.
// L421: `if (std::abs(arcamount) > 1e-6f) pathpos += arcamount * 4.0f * t * (1-t) * arc_dir * lineLen;`
// L430: `if (size_reduction > 1e-6f) ...`
// L432: `if (scale < 0.0f) scale = 0.0f;` — clamp negative scale.
// L441: `Vector3f noise_perp(-line.y(), line.x(), 0.0f);` — noise direction.
// L442: `if (noise_perp.squaredNorm() < 1e-6f) noise_perp = Vector3f(0,0,1);`
// L447-450: noise amplitude / sinusoid weights.

TEST_SUITE("mapsequencebetweencontrolpoints arc/size") {
    TEST_CASE("arcamount=0: no arc bulge applied (kills `> 1e-6f` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 2 }, { "arcamount", 0.0 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();  // first idx=0 → t=0 → no arc, no noise (taper=0).
        seededInit(init, p, 0.0);
        // Without arc and at endpoints (taper=sin(0)=0 / sin(pi)=0), pos == cp0.
        CHECK(p.position.x() == doctest::Approx(0.0f).epsilon(0.05));
        CHECK(p.position.y() == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("arcamount=1, count=3, mid particle has y>0 arc bulge") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 3 }, { "arcamount", 0.5 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        // Skip first (t=0), grab second (t=0.5, midpoint).
        Particle p0 = makeParticle();
        seededInit(init, p0, 0.0);
        Particle p1 = makeParticle();
        seededInit(init, p1, 0.0);
        // arc: pathpos += 0.5*4*0.5*0.5 * arc_dir(0,1,0) * 100 = 50*arc_dir.
        // arc_dir is screen-perp of line(100,0,0) = (0,100,0) normalised = (0,1,0).
        // So midpoint y ≈ 50.
        CHECK(p1.position.y() > 30.0f);   // arc clearly active
        CHECK(p1.position.y() < 70.0f);   // not blown up
    }

    TEST_CASE("size_reduction=1.0, last particle scaled toward 0 (`scale<0.0f` clamp branch)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 2 }, { "sizereductionamount", 1.0 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p0 = makeParticle();
        p0.size     = 10.0f;
        p0.init.size = 10.0f;
        seededInit(init, p0, 0.0);    // t=0 → scale=1.0 → 10.
        CHECK(p0.size == doctest::Approx(10.0f).epsilon(0.05));

        Particle p1 = makeParticle();
        p1.size     = 10.0f;
        p1.init.size = 10.0f;
        seededInit(init, p1, 0.0);    // t=1 → scale=1-1*1=0 (or clamped to 0).
        CHECK(p1.size == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("size_reduction=2.0 (>1) clamps trailing particle's scale to 0") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 2 }, { "sizereductionamount", 2.0 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p0 = makeParticle();
        p0.size = 10.0f;
        seededInit(init, p0, 0.0);
        Particle p1 = makeParticle();
        p1.size = 10.0f;
        seededInit(init, p1, 0.0);
        // p1 t=1 → scale = 1 - 2 = -1, clamped to 0.
        CHECK(p1.size == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("size_reduction=0: no MutiplySize (kills `> 1e-6` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "mapsequencebetweencontrolpoints" },
                   { "count", 2 }, { "sizereductionamount", 0.0 },
                   { "controlpointstart", 0 }, { "controlpointend", 1 } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p1 = makeParticle();
        p1.size = 10.0f;
        seededInit(init, p1, 0.0); // seq=0 → t=0 → scale=1 (would be no-op anyway)
        Particle p2 = makeParticle();
        p2.size = 10.0f;
        seededInit(init, p2, 0.0); // seq=1 → t=1 → scale=1 (size_reduction=0 keeps it 1)
        CHECK(p2.size == doctest::Approx(10.0f).epsilon(0.05));
    }
}

// ============================================================================
// hsvcolorrandom — lines 475, 477, 480, 481, 486, 487
// ============================================================================
//
// L475: `float h_range = huemax - huemin;`
// L477: `const float h_quant = (huesteps >= 1) ? (h_range / (float)huesteps) : 0.0f;`
// L480: `if (h_quant > 0.0f)` — quantised path.
// L481: `int bucket = Random::get(0, std::max(0, huesteps - 1));`
// L486: `float s = satmin + Random::get(0,1)*std::max(0, satmax - satmin);`

TEST_SUITE("hsvcolorrandom residual") {
    TEST_CASE("huesteps=0: continuous hue (h_quant=0 path)") {
        json j = { { "name", "hsvcolorrandom" },
                   { "huemin", 0.0 }, { "huemax", 360.0 },
                   { "saturationmin", 1.0 }, { "saturationmax", 1.0 },
                   { "valuemin", 1.0 }, { "valuemax", 1.0 },
                   { "huesteps", 0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 10; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            CHECK(p.color.x() >= 0.0f); CHECK(p.color.x() <= 1.0f);
            CHECK(p.color.y() >= 0.0f); CHECK(p.color.y() <= 1.0f);
            CHECK(p.color.z() >= 0.0f); CHECK(p.color.z() <= 1.0f);
        }
    }

    TEST_CASE("huesteps=1: bucket fixed at 0 → exact huemin (Random::get(0, max(0,0)))") {
        // huesteps=1, max(0, 0) = 0 → bucket = Random::get(0, 0) = 0.
        // h_quant = 360/1 = 360.  h = huemin + 0*360 = huemin.
        json j = { { "name", "hsvcolorrandom" },
                   { "huemin", 0.0 }, { "huemax", 360.0 },
                   { "saturationmin", 1.0 }, { "saturationmax", 1.0 },
                   { "valuemin", 1.0 }, { "valuemax", 1.0 },
                   { "huesteps", 1 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 5; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            // h=0 → red → r=1, g=0, b=0 (HsvToRgb(0, 1, 1)).
            CHECK(p.color.x() == doctest::Approx(1.0f).epsilon(0.05));
            CHECK(p.color.y() == doctest::Approx(0.0f).epsilon(0.05));
            CHECK(p.color.z() == doctest::Approx(0.0f).epsilon(0.05));
        }
    }

    TEST_CASE("satmin=1.0 satmax=1.0: max(0, 0)=0 → s exactly satmin") {
        // Pin saturation to be exact, then test color (red at hue=0, sat=1, val=1) is exact.
        json j = { { "name", "hsvcolorrandom" },
                   { "huemin", 0.0 }, { "huemax", 0.0 },
                   { "saturationmin", 1.0 }, { "saturationmax", 1.0 },
                   { "valuemin", 0.5 }, { "valuemax", 0.5 },
                   { "huesteps", 0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        // Hue=0 sat=1 val=0.5 → red half-lit.
        CHECK(p.color.x() == doctest::Approx(0.5f).epsilon(0.05));
        CHECK(p.color.y() == doctest::Approx(0.0f).epsilon(0.05));
        CHECK(p.color.z() == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("satmax < satmin: max(0, neg)=0 → s exactly satmin (kills `-` mutation)") {
        // satmin=0.6, satmax=0.4 → max(0, 0.4-0.6) = max(0, -0.2) = 0.
        // s = 0.6 + rand*0 = 0.6.
        json j = { { "name", "hsvcolorrandom" },
                   { "huemin", 0.0 }, { "huemax", 0.0 },
                   { "saturationmin", 0.6 }, { "saturationmax", 0.4 },
                   { "valuemin", 1.0 }, { "valuemax", 1.0 },
                   { "huesteps", 0 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 5; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            // Hue=0, sat=0.6, val=1 → HsvToRgb returns (1, 0.4, 0.4).
            CHECK(p.color.x() == doctest::Approx(1.0f).epsilon(0.05));
            CHECK(p.color.y() == doctest::Approx(0.4f).epsilon(0.05));
            CHECK(p.color.z() == doctest::Approx(0.4f).epsilon(0.05));
        }
    }
}

// ============================================================================
// colorlist 0..255 normalisation — lines 511, 522, 531-533
// ============================================================================
//
// L511: `if (col[0] > 1.5f || col[1] > 1.5f || col[2] > 1.5f)` — three `>` boundaries.
// L522: `const bool jitter = (huenoise > 1e-6f) || (satnoise > 1e-6f) || (valnoise > 1e-6f);`
// L531-533: hsv jitter applied (`huenoise` etc. multiplied with random in [-1,1]).

TEST_SUITE("colorlist normalization residual") {
    TEST_CASE("color value at exact 1.5 is NOT scaled (boundary `> 1.5f`)") {
        // 1.5 is the tipping point — anything strictly > 1.5 gets /255.
        json j = { { "name", "colorlist" },
                   { "colors", { "1.5 0.0 0.0" } } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.color.x() == doctest::Approx(1.5f).epsilon(0.01));
    }

    TEST_CASE("color [255, 0, 0]: r normalized to 1.0") {
        json j = { { "name", "colorlist" },
                   { "colors", { "255 0 0" } } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.color.x() == doctest::Approx(1.0f).epsilon(0.01));
        CHECK(p.color.y() == doctest::Approx(0.0f).epsilon(0.01));
    }

    TEST_CASE("color in 0..1 form passes through (kills `> 1.5f` flip)") {
        json j = { { "name", "colorlist" },
                   { "colors", { "0.5 0.5 0.5" } } };
        auto init = WPParticleParser::genParticleInitOp(j);
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        CHECK(p.color.x() == doctest::Approx(0.5f).epsilon(0.01));
    }

    TEST_CASE("any of huenoise / satnoise / valnoise > 1e-6 enables jitter path") {
        // A specific case: pure huenoise drives the jitter path.  Output stays in [0,1].
        json j = { { "name", "colorlist" },
                   { "colors", { "1.0 0.0 0.0" } },
                   { "huenoise", 0.1 } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 10; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            CHECK(p.color.x() >= 0.0f); CHECK(p.color.x() <= 1.05f);
            CHECK(p.color.y() >= 0.0f); CHECK(p.color.y() <= 1.05f);
            CHECK(p.color.z() >= 0.0f); CHECK(p.color.z() <= 1.05f);
        }
    }

    TEST_CASE("all noise = 0: jitter disabled (kills `> 1e-6f` triple-OR)") {
        json j = { { "name", "colorlist" },
                   { "colors", { "0.4 0.6 0.8" } } };
        auto init = WPParticleParser::genParticleInitOp(j);
        for (int i = 0; i < 5; i++) {
            Particle p = makeParticle();
            seededInit(init, p, 0.0);
            // No jitter → exact pass-through.
            CHECK(p.color.x() == doctest::Approx(0.4f).epsilon(0.001));
            CHECK(p.color.y() == doctest::Approx(0.6f).epsilon(0.001));
            CHECK(p.color.z() == doctest::Approx(0.8f).epsilon(0.001));
        }
    }
}

// ============================================================================
// mapsequencearoundcontrolpoint cp_id boundary — line 616 (`<` vs `<=`)
// ============================================================================

TEST_SUITE("mapsequencearoundcontrolpoint cp boundary") {
    TEST_CASE("cp_id within range: cp_offset applied (kills `<` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(50, 50, 50);
        cps[1].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "mapsequencearoundcontrolpoint" },
                   { "count", 4 },
                   { "controlpoint", 0 },
                   { "axis", "10 10 0" } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        // First (idx=0): pos = cp0(50,50,50) + (10*cos(0), 10*sin(0), 0) = (60, 50, 50).
        CHECK(p.position.x() == doctest::Approx(60.0f).epsilon(0.05));
        CHECK(p.position.y() == doctest::Approx(50.0f).epsilon(0.05));
        CHECK(p.position.z() == doctest::Approx(50.0f).epsilon(0.05));
    }

    TEST_CASE("cp_id at cp_size (out of range): cp_offset stays 0") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(50, 50, 50);
        cps[1].resolved = Eigen::Vector3d(11, 22, 33);
        json j = { { "name", "mapsequencearoundcontrolpoint" },
                   { "count", 4 },
                   { "controlpoint", 7 },  // index 7, size=2 → out of range
                   { "axis", "10 10 0" } };
        auto init = WPParticleParser::genParticleInitOp(
            j, std::span<const ParticleControlpoint>(cps.data(), cps.size()));
        Particle p = makeParticle();
        seededInit(init, p, 0.0);
        // cp_offset = (0,0,0); pos = (10, 0, 0) at idx=0.
        CHECK(p.position.x() == doctest::Approx(10.0f).epsilon(0.05));
        CHECK(p.position.y() == doctest::Approx(0.0f).epsilon(0.05));
        CHECK(p.position.z() == doctest::Approx(0.0f).epsilon(0.05));
    }
}

// ============================================================================
// genOverrideInitOp brightness branch — line 664 (`>` vs `>=`)
// ============================================================================
//
// `if (over.brightness > 1.0f) { PM::MutiplyInitAlpha(p, 1.0f / sqrt(brightness)) }`
// — at exactly 1.0 the alpha is NOT divided.  Pin both 1.0 (no divide) and
// 1.000001 (divide).

TEST_SUITE("genOverrideInitOp brightness boundary") {
    TEST_CASE("brightness == 1.0 exactly: no alpha divide (kills `>` → `>=`)") {
        wpscene::ParticleInstanceoverride over;
        over.enabled    = true;
        over.brightness = 1.0f;
        over.alpha      = 1.0f;
        over.size       = 1.0f;
        over.speed      = 1.0f;
        over.lifetime   = 0.0f;  // multiplier path
        over.overColor  = false;
        over.overColorn = false;
        auto op = WPParticleParser::genOverrideInitOp(over);
        Particle p = makeParticle();
        p.alpha       = 1.0f;
        p.init.alpha  = 1.0f;
        op(p, 0.0);
        // brightness branch short-circuits since `1.0f != 1.0f` is false on
        // the OUTER guard (line 662) — the inner `> 1.0f` divide never runs.
        CHECK(p.alpha == doctest::Approx(1.0f).epsilon(0.001));
    }

    TEST_CASE("brightness slightly > 1.0: alpha IS divided") {
        wpscene::ParticleInstanceoverride over;
        over.enabled    = true;
        over.brightness = 4.0f;
        over.alpha      = 1.0f;
        over.size       = 1.0f;
        over.speed      = 1.0f;
        over.overColor  = false;
        over.overColorn = false;
        auto op = WPParticleParser::genOverrideInitOp(over);
        Particle p = makeParticle();
        p.alpha       = 1.0f;
        p.init.alpha  = 1.0f;
        op(p, 0.0);
        // 1.0 / sqrt(4.0) = 0.5
        CHECK(p.alpha == doctest::Approx(0.5f).epsilon(0.05));
    }
}

// ============================================================================
// FadeValueChange — lines 672, 674, 677
// ============================================================================
//
// L672: `if (life <= start) return startValue;`
// L674: `else if (life > end) return endValue;`
// L677: `pass = (life - start) / (end - start);`

TEST_SUITE("FadeValueChange residual") {
    // FadeValueChange isn't directly callable; reach via alphachange operator.
    TEST_CASE("life exactly == endtime: lerps to endvalue (life <= end is FALSE)") {
        json j = { { "name", "alphachange" },
                   { "starttime", 0.0 }, { "endtime", 0.5 },
                   { "startvalue", 1.0 }, { "endvalue", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        // Set lifetime so LifetimePos(p) == 0.5
        p.lifetime      = 0.5f;
        p.init.lifetime = 1.0f;
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        // life = 1 - lifetime/init = 1 - 0.5 = 0.5.
        // life <= 0.5 (start <= end), 0.5 > 0.5 is false → lerp branch.
        // pass = (0.5 - 0)/(0.5 - 0) = 1.0; lerp(1.0, 1.0, 0.0) = 0.0.
        // alpha = 1.0 * 0.0 = 0.0.
        CHECK(out.alpha == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("life beyond endtime: returns endvalue exactly (kills `>` boundary)") {
        json j = { { "name", "alphachange" },
                   { "starttime", 0.0 }, { "endtime", 0.5 },
                   { "startvalue", 1.0 }, { "endvalue", 0.25 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.lifetime      = 0.2f;          // life pos = 1 - 0.2 = 0.8 > 0.5
        p.init.lifetime = 1.0f;
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        // alpha = 1.0 * 0.25 = 0.25.
        CHECK(out.alpha == doctest::Approx(0.25f).epsilon(0.05));
    }
}

// ============================================================================
// FrequencyValue::CheckAndResize — line 755 (`<` vs `<=`)
// ============================================================================
//
// `if (storage.size() < s) storage.resize(2 * s, ...);`
// At s == storage.size(), no resize.  We can drive this through oscillatealpha
// behaviour: size of internal storage matches particle count.

TEST_SUITE("FrequencyValue storage boundary") {
    TEST_CASE("oscillatealpha with fixed particle count: storage allocates 2x once") {
        json j = { { "name", "oscillatealpha" },
                   { "frequencymin", 1.0 }, { "frequencymax", 1.0 },
                   { "scalemin", 1.0 }, { "scalemax", 1.0 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        // 5 particles, repeatedly run.  Storage grows once to 10, then stays.
        std::vector<Particle> ps;
        ps.reserve(20);
        for (int i = 0; i < 5; i++) ps.push_back(makeParticle());
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        for (int rep = 0; rep < 3; rep++) op(info);
        // alpha should be multiplied by lerp(scalemin, scalemax) = 1.0 → unchanged.
        for (auto& p : ps) CHECK(p.alpha == doctest::Approx(1.0f).epsilon(0.001));
    }
}

// ============================================================================
// FrequencyValue GetScale / GetMove — lines 769-777
// ============================================================================
//
// L769-771: GetScale `f = freq/(2π); w = 2π*f`. (Trivial round-trip; mutations
//           collapse to drift in lerp argument.)
// L775-777: GetMove `-1 * scale * w * sin(w*t + phase) * timepass`
//
// Test by running oscillatealpha with frequency=1 over a known time so the
// cosine evaluates to a known value.

TEST_SUITE("FrequencyValue GetScale residual") {
    TEST_CASE("oscillatealpha: scalemin=scalemax=0 forces alpha to 0 regardless of freq") {
        json j = { { "name", "oscillatealpha" },
                   { "frequencymin", 5.0 }, { "frequencymax", 5.0 },
                   { "scalemin", 0.0 }, { "scalemax", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.alpha    = 1.0f;
        auto out   = runOpSingle(op, p, 0.016, 0.5);
        CHECK(out.alpha == doctest::Approx(0.0f).epsilon(0.001));
    }

    TEST_CASE("oscillatealpha: scalemin=1 scalemax=1 → alpha unchanged regardless of t") {
        json j = { { "name", "oscillatealpha" },
                   { "frequencymin", 7.0 }, { "frequencymax", 7.0 },
                   { "scalemin", 1.0 }, { "scalemax", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.alpha    = 0.7f;
        auto out   = runOpSingle(op, p, 0.016, 1.234);
        CHECK(out.alpha == doctest::Approx(0.7f).epsilon(0.001));
    }
}

// ============================================================================
// Vortex / ControlPointForce JSON read — lines 839, 872 (controlpoint clamp)
// ============================================================================
//
// L839: `if (v.controlpoint >= 8) LOG_ERROR(...)` then `v.controlpoint %= 8`.
// L872: same for ControlPointForce.

TEST_SUITE("Vortex/ControlPointForce CP clamp") {
    TEST_CASE("vortex with controlpoint=8 wraps to 0 via %= 8") {
        json j = { { "name", "vortex" },
                   { "controlpoint", 8 },  // wraps to 0
                   { "axis", "0 0 1" },
                   { "speedinner", 100.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(10, 0, 0);  // inside 50, near CP0 (origin).
        auto out = runOpSingle(op, p);
        // Inner zone: applies acceleration along -axis × pos = (0,1,0)*100 over dt.
        CHECK(std::abs(out.velocity.y()) > 0.5f);   // accelerated in y
    }

    TEST_CASE("vortex with controlpoint=10 wraps to 2 (offset)") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[2].resolved = Eigen::Vector3d(100, 0, 0); // CP2 at (100,0,0)
        json j = { { "name", "vortex" },
                   { "controlpoint", 10 },  // wraps to 2
                   { "axis", "0 0 1" },
                   { "speedinner", 100.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(110, 0, 0); // close to CP2
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(std::isfinite(out.velocity.norm()));
    }

    TEST_CASE("controlpointattract with controlpoint=15 wraps to 7") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[7].resolved = Eigen::Vector3d(50, 0, 0);
        json j = { { "name", "controlpointattract" },
                   { "controlpoint", 15 },  // wraps to 7
                   { "scale", 100.0 }, { "threshold", 1000.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 0, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        // Should be attracted along +x toward (50,0,0).
        CHECK(out.velocity.x() > 0.0f);
    }
}

// ============================================================================
// movement gravity scale & drag boundary — lines 912, 913
// ============================================================================
//
// L912: `if (drag > 0.0f)` — drag sub-branch.
// L913: `double factor = std::max(0.0, 1.0 - drag * f * info.time_pass);`
// (drag * f * time_pass — kills `*` → `/` on the time_pass piece.)

TEST_SUITE("movement drag residual") {
    TEST_CASE("drag > 0 pulls down velocity by factor (1 - drag*dt) at f=1") {
        json j = { { "name", "movement" }, { "drag", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.velocity = Eigen::Vector3f(100, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        // factor = 1 - 0.5*1*1 = 0.5 → 100*0.5 = 50.
        CHECK(out.velocity.x() == doctest::Approx(50.0f).epsilon(0.05));
    }

    TEST_CASE("drag with very small dt: factor close to 1 (kills mul → div)") {
        // dt=0.001, drag=10 → factor = 1 - 10*0.001 = 0.99.
        // Mutated div: factor = 1 - 10/0.001 = 1 - 10000 → clamped to 0.
        json j = { { "name", "movement" }, { "drag", 10.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.velocity = Eigen::Vector3f(100, 0, 0);
        auto out = runOpSingle(op, p, 0.001);
        CHECK(out.velocity.x() > 95.0f);  // ~99 not 0
        CHECK(out.velocity.x() < 100.5f);
    }
}

// ============================================================================
// alphafade window comparisons — lines 961, 966, 970
// ============================================================================
//
// Defensive guard: `if (p.init.lifetime <= 1e-6f) continue;`
// Fade-in branch: `if (age01 < fadeintime) a = age01 / fadeintime;`
// Fade-out branch: `else if (age01 > fadeouttime) a = (1-age01)/(1-fadeouttime);`

TEST_SUITE("alphafade boundary") {
    TEST_CASE("very small init.lifetime (<= 1e-6) skips particle") {
        json j = { { "name", "alphafade" },
                   { "fadeintime", 0.5 }, { "fadeouttime", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.init.lifetime = 1e-7f;
        p.lifetime      = 1e-7f;
        p.alpha         = 0.7f;
        auto out = runOpSingle(op, p);
        // Skipped — alpha unchanged.
        CHECK(out.alpha == doctest::Approx(0.7f).epsilon(0.001));
    }

    TEST_CASE("age01 exactly at fadeintime boundary: plateau, not fade-in (kills `<` boundary)") {
        // fadeintime=fadeouttime=0.5 → tent peak at age01=0.5.  At the exact
        // boundary `age01 < 0.5` is false, so the fade-in branch is skipped;
        // `age01 > 0.5` is also false, so fade-out skipped → a=1.0.
        json j = { { "name", "alphafade" },
                   { "fadeintime", 0.5 }, { "fadeouttime", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.init.lifetime = 1.0f;
        p.lifetime      = 0.5f;   // age01 = 1 - 0.5 = 0.5
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.alpha == doctest::Approx(1.0f).epsilon(0.001));
    }

    TEST_CASE("fadeintime=0, fadeouttime=1: no fade in either direction") {
        // Edge: fade-in ends at 0 → fade-in window empty; fade-out starts at 1
        // → fade-out window empty.  Alpha unchanged for any age01.
        json j = { { "name", "alphafade" },
                   { "fadeintime", 0.0 }, { "fadeouttime", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.init.lifetime = 1.0f;
        p.lifetime      = 0.999f;   // age01 ~ 0.001
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.alpha == doctest::Approx(1.0f).epsilon(0.001));
    }

    TEST_CASE("age01 past fadeouttime scales alpha down (kills `>` boundary)") {
        // fadeouttime=0.5.  age01=0.75 > 0.5 → a = (1-0.75)/(1-0.5) = 0.5.
        json j = { { "name", "alphafade" },
                   { "fadeintime", 0.0 }, { "fadeouttime", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.init.lifetime = 1.0f;
        p.lifetime      = 0.25f;   // age01 = 1 - 0.25 = 0.75
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.alpha == doctest::Approx(0.5f).epsilon(0.05));
    }
}

// ============================================================================
// oscillatealpha / oscillatesize / oscillateposition — lines 999, 1009, 1022, 1026, 1027
// ============================================================================
//
// These all use `for (uint i = 0; i < info.particles.size(); i++)` with
// post-increment `i++`.  Post-inc → post-dec (i--) would underflow uint and
// loop forever.  We can't test infinite loops — but we can test that with N>1
// particles all get processed.

TEST_SUITE("oscillate per-particle iteration") {
    TEST_CASE("oscillatealpha applies to every particle (kills i++ → i--)") {
        json j = { { "name", "oscillatealpha" },
                   { "frequencymin", 1.0 }, { "frequencymax", 1.0 },
                   { "scalemin", 0.0 }, { "scalemax", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        for (int i = 0; i < 4; i++) {
            Particle p = makeParticle();
            p.alpha = 1.0f;
            ps.push_back(p);
        }
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        // ALL particles must be at scale=0 → alpha=0.
        for (auto& p : ps) CHECK(p.alpha == doctest::Approx(0.0f).epsilon(0.001));
    }

    TEST_CASE("oscillatesize applies to every particle") {
        json j = { { "name", "oscillatesize" },
                   { "frequencymin", 1.0 }, { "frequencymax", 1.0 },
                   { "scalemin", 0.5 }, { "scalemax", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        for (int i = 0; i < 4; i++) {
            Particle p = makeParticle();
            p.size = 10.0f;
            ps.push_back(p);
        }
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        for (auto& p : ps) CHECK(p.size == doctest::Approx(5.0f).epsilon(0.05));
    }

    TEST_CASE("oscillateposition mask=0,0,0 leaves position unchanged") {
        json j = { { "name", "oscillateposition" },
                   { "frequencymin", 1.0 }, { "frequencymax", 1.0 },
                   { "scalemin", 1.0 }, { "scalemax", 1.0 },
                   { "mask", "0 0 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        for (int i = 0; i < 3; i++) {
            Particle p = makeParticle();
            p.position = Eigen::Vector3f(10, 20, 30);
            ps.push_back(p);
        }
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        // mask=0 path skips per-axis update — position unchanged.
        for (auto& p : ps) {
            CHECK(p.position.x() == doctest::Approx(10.0f));
            CHECK(p.position.y() == doctest::Approx(20.0f));
            CHECK(p.position.z() == doctest::Approx(30.0f));
        }
    }
}

// ============================================================================
// turbulence noiseRate / incoherent path — lines 1042, 1043, 1047, 1053
// ============================================================================
//
// L1042: `noiseRate = std::abs(timescale * scale * 2.0) * info.time_pass;`
// L1043: `bool incoherent = noiseRate > 1.0;`
// L1047: `factor = speed * bw.Factor(p);`
// L1053: `samplePos.x() += phase + info.time * tur.scale * 2.0;`

TEST_SUITE("turbulence noiseRate boundary") {
    TEST_CASE("incoherent path triggered when timescale*scale*2*dt > 1") {
        // timescale=20, scale=0.05, dt=1: 20*0.05*2*1 = 2.0 > 1.0 → incoherent.
        json j = { { "name", "turbulence" },
                   { "speedmin", 100.0 }, { "speedmax", 100.0 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 },
                   { "timescale", 20.0 }, { "scale", 0.05 },
                   { "mask", "1 1 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(1, 1, 1);
        // Force incoherent path — z mask=0 → z motion zero.
        auto out = runOpSingle(op, p, 1.0, 0.0);
        // z should stay at 1 (mask=0 zeros z component).
        CHECK(out.position.z() == doctest::Approx(1.0f));
        CHECK(std::isfinite(out.velocity.norm()));
    }

    TEST_CASE("coherent path: very small noiseRate keeps mask=0 axis still") {
        // timescale=0.1, scale=0.1, dt=0.016: 0.1*0.1*2*0.016 = 0.00032 << 1.
        json j = { { "name", "turbulence" },
                   { "speedmin", 100.0 }, { "speedmax", 100.0 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 },
                   { "timescale", 0.1 }, { "scale", 0.1 },
                   { "mask", "1 1 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(1, 1, 1);
        p.velocity = Eigen::Vector3f(0, 0, 5);  // existing z velocity
        auto out = runOpSingle(op, p, 0.016, 0.0);
        // Coherent: z mask=0 → no z accel applied.  Existing z velocity preserved.
        CHECK(out.velocity.z() == doctest::Approx(5.0f).epsilon(0.05));
    }
}

// ============================================================================
// vortex middle zone — lines 1077, 1084, 1087, 1089, 1090
// ============================================================================
//
// L1077: `dis_mid = v.distanceouter - v.distanceinner + 0.1f;`
// L1084: `if (dis_mid < 0 || distance < v.distanceinner) ...` (inner branch)
// L1087: `if (distance > v.distanceouter) ...` (outer branch)
// L1089: `else if (distance > v.distanceinner) ...` (middle branch)
// L1090: `t = (distance - v.distanceinner) / dis_mid;`

TEST_SUITE("vortex middle zone residual") {
    TEST_CASE("vortex distance == distanceinner (boundary <): inner branch hit") {
        // distance == distanceinner: `distance < distanceinner` false → middle/outer.
        // But middle is `distance > distanceinner`, also false → no acceleration.
        json j = { { "name", "vortex" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" },
                   { "speedinner", 100.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);  // distance == 50 == distanceinner
        auto out = runOpSingle(op, p, 1.0);
        // Neither branch: distance not < 50, not > 100, not > 50.  No accel.
        CHECK(out.velocity.norm() == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("vortex middle: distance just past distanceinner gets lerped speed") {
        // dis_mid = 200-100+0.1 = 100.1.  At distance=125, t=(125-100)/100.1≈0.25.
        // lerp(0.25, 100, 200) = 125.
        json j = { { "name", "vortex" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" },
                   { "speedinner", 100.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 100.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(125, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        // Tangential accel along -axis × pos.  axis=(0,0,1), pos=(125,0,0):
        //   -axis × pos = -(0,0,1) × (125,0,0) = -(0*0 - 1*0, 1*125 - 0*0, 0*0 - 0*125)
        //              = -(0, 125, 0) = (0, -125, 0)
        // direct.normalized() = (0, -1, 0); magnitude = lerp(0.25, 100, 200) ≈ 125.
        // So velocity.y is NEGATIVE.
        CHECK(out.velocity.y() < -100.0f);
        CHECK(out.velocity.y() > -150.0f);
    }
}

// ============================================================================
// vortex_v2 ring radius — lines 1129, 1131, 1134, 1138, 1143, 1146, 1158, 1161
// ============================================================================
//
// L1129: `if (dis_mid < 0 || distance < v.distanceinner) speed = speedinner;`
// L1131: `else if (distance > v.distanceouter) speed = speedouter;`
// L1134: `t = (distance - distanceinner) / dis_mid; speed = lerp(t, inner, outer);`
// L1138: `if (use_rotation && distance > 0.001)`
// L1143: `angle = (speed / distance) * info.time_pass;`
// L1146: `axis * axis.dot(radial) * (1.0 - cos(angle))`
// L1158: `if (ringradius > 0 && distance > 0.001)`
// L1161: `if (std::abs(radiusDiff) > ringwidth)`

TEST_SUITE("vortex_v2 ring residual") {
    TEST_CASE("vortex_v2 distance < distanceinner: speed = speedinner") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 50.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 100.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);  // inside inner
        auto out = runOpSingle(op, p, 1.0);
        // Tangential along (0,1,0)*50.
        CHECK(out.velocity.y() == doctest::Approx(50.0f).epsilon(0.1));
    }

    TEST_CASE("vortex_v2 distance > distanceouter: speed = speedouter") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 50.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 100.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(300, 0, 0);  // outside outer
        auto out = runOpSingle(op, p, 1.0);
        CHECK(out.velocity.y() == doctest::Approx(200.0f).epsilon(0.1));
    }

    TEST_CASE("vortex_v2 use_rotation (flags=2) at distance > 0.001 rotates radial") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 2 },
                   { "speedinner", 100.0 }, { "speedouter", 100.0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(100, 0, 0);
        p.velocity = Eigen::Vector3f(99, 99, 99);
        auto out = runOpSingle(op, p, 0.1);
        // Rotation mode: position rotated by angle = (speed/dist)*dt.
        // velocity zeroed.
        CHECK(out.velocity.norm() == doctest::Approx(0.0f).epsilon(0.001));
        // Position magnitude preserved (radial preserved, dot(radial, axis)=0 so 3rd term=0).
        CHECK(out.position.norm() == doctest::Approx(100.0f).epsilon(0.05));
    }

    TEST_CASE("vortex_v2 ringradius=0 disables ring pull (kills `> 0` boundary)") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 0.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 100.0 }, { "distanceouter", 200.0 },
                   { "ringradius", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        CHECK(out.velocity.norm() == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("vortex_v2 ringradius pulls inside particle outward") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 0.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 1000.0 }, { "distanceouter", 2000.0 },
                   { "ringradius", 100.0 }, { "ringwidth", 1.0 },
                   { "ringpulldistance", 50.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);  // distance=50, ringradius=100, diff=50>1.
        auto out = runOpSingle(op, p, 1.0);
        // radialDir = (1,0,0); radiusDiff=50>0 → sign=1; pull = +x by 50.
        CHECK(out.velocity.x() == doctest::Approx(50.0f).epsilon(0.05));
    }

    TEST_CASE("vortex_v2 ringradius pulls outside particle inward") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 0.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 1000.0 }, { "distanceouter", 2000.0 },
                   { "ringradius", 100.0 }, { "ringwidth", 1.0 },
                   { "ringpulldistance", 50.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(200, 0, 0);  // dist=200, ringradius=100, diff=-100.
        auto out = runOpSingle(op, p, 1.0);
        // radialDir = (1,0,0); diff=-100, sign=-1 → -x by 50.
        CHECK(out.velocity.x() == doctest::Approx(-50.0f).epsilon(0.05));
    }

    TEST_CASE("vortex_v2 |radiusDiff| <= ringwidth: no pull (kills `>` boundary)") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 0.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 1000.0 }, { "distanceouter", 2000.0 },
                   { "ringradius", 100.0 }, { "ringwidth", 50.0 },
                   { "ringpulldistance", 999.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(120, 0, 0);  // diff=-20, |diff|<width=50 → no pull.
        auto out = runOpSingle(op, p, 1.0);
        CHECK(out.velocity.norm() == doctest::Approx(0.0f).epsilon(0.05));
    }
}

// ============================================================================
// maintaindistancebetweencontrolpoints — lines 1173, 1178, 1184
// ============================================================================
//
// L1173: `if (info.controlpoints.size() < 2) return;`
// L1178: `if (len2 < 1e-10) return;`
// L1184: `if (projT < 0.0 || projT > 1.0) clamp...`

TEST_SUITE("maintaindistancebetweencontrolpoints residual") {
    TEST_CASE("only 1 CP → no-op early return (kills `< 2` boundary)") {
        std::vector<ParticleControlpoint> cps(1, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "maintaindistancebetweencontrolpoints" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(500, 500, 500);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.position.x() == doctest::Approx(500.0f));
    }

    TEST_CASE("identical CPs (len2 < 1e-10) → early return") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(50, 50, 50);
        cps[1].resolved = Eigen::Vector3d(50, 50, 50);  // same point
        json j = { { "name", "maintaindistancebetweencontrolpoints" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(999, 999, 999);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.position.x() == doctest::Approx(999.0f));
    }

    TEST_CASE("projT exactly at 1.0: in range → no clamp (kills `> 1.0` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "maintaindistancebetweencontrolpoints" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(100, 0, 0);  // exactly at cp1
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        // projT = 1.0 → not < 0, not > 1 → no clamp.
        CHECK(out.position.x() == doctest::Approx(100.0f).epsilon(0.001));
    }

    TEST_CASE("projT exactly at 0.0: in range → no clamp (kills `< 0.0` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "maintaindistancebetweencontrolpoints" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 0, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.position.x() == doctest::Approx(0.0f).epsilon(0.001));
    }
}

// ============================================================================
// reducemovementnearcontrolpoint — lines 1204, 1210, 1213
// ============================================================================
//
// L1204: `if ((usize)controlpoint >= info.controlpoints.size()) return;`
// L1210: `if (dist >= distanceouter) continue;`
// L1213: `if (dist <= distanceinner) reduction = reductioninner;`

TEST_SUITE("reducemovementnearcontrolpoint residual") {
    TEST_CASE("controlpoint out of range: early return (kills `>=` → `>`)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "reducemovementnearcontrolpoint" },
                   { "controlpoint", 7 },  // Clamp gives 7, but cps.size()=2.
                   { "distanceinner", 0.0 }, { "distanceouter", 100.0 },
                   { "reductioninner", 1000.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.velocity = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        // Out of range → unchanged.
        CHECK(out.velocity.x() == doctest::Approx(50.0f));
    }

    TEST_CASE("dist >= distanceouter exactly: skipped (kills `>=` boundary)") {
        json j = { { "name", "reducemovementnearcontrolpoint" },
                   { "controlpoint", 0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 100.0 },
                   { "reductioninner", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(100, 0, 0);  // dist == distanceouter
        p.velocity = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.x() == doctest::Approx(50.0f));
    }

    TEST_CASE("dist <= distanceinner: full inner reduction (boundary `<=`)") {
        json j = { { "name", "reducemovementnearcontrolpoint" },
                   { "controlpoint", 0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 100.0 },
                   { "reductioninner", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);  // dist == distanceinner
        p.velocity = Eigen::Vector3f(100, 0, 0);
        // factor = 1 / (1 + 1.0 * 0.016) ≈ 0.984.
        auto out = runOpSingle(op, p, 0.016);
        CHECK(out.velocity.x() < 100.0f);
        CHECK(out.velocity.x() > 95.0f);
    }
}

// ============================================================================
// remapvalue transformoctaves clamp & timeofday — lines 1257, 1258, 1318
// ============================================================================
//
// L1257: `if (transformoctaves < 1) transformoctaves = 1;`
// L1258: `if (transformoctaves > 8) transformoctaves = 8;`
// L1318: `if (comp == "x+y") return v.x() + v.y();` — kills `+` → `-`.

TEST_SUITE("remapvalue clamp/component residual") {
    TEST_CASE("transformoctaves negative clamps to 1: noise still finite") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "fbmnoise" },
                   { "transformoctaves", -3 },
                   { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.5);
        CHECK(std::isfinite(out.size));
        CHECK(out.size >= 0.0f);
        CHECK(out.size <= 100.0f);
    }

    TEST_CASE("transformoctaves=99 clamps to 8") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "fbmnoise" },
                   { "transformoctaves", 99 },
                   { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.5);
        CHECK(std::isfinite(out.size));
    }

    TEST_CASE("inputcomponent x+y sums x and y (kills `+` → `-`)") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(10, 5, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "controlpoint" },
                   { "inputcomponent", "x+y" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size     = 0.0f;
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        // raw = 10 + 5 = 15 → t = 15/100 = 0.15 → mapped = 15.
        CHECK(ps[0].size == doctest::Approx(15.0f).epsilon(0.05));
    }

    TEST_CASE("inputcomponent x+y with `-` mutation difference: x=10, y=-7 → 3 vs 17") {
        // If `+` flipped to `-`, raw would be 10 - (-7) = 17, not 10 + (-7) = 3.
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(10, -7, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "controlpoint" },
                   { "inputcomponent", "x+y" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size     = 0.0f;
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        // raw = 10 + (-7) = 3 → t = 0.03 → mapped = 3.
        CHECK(ps[0].size == doctest::Approx(3.0f).epsilon(0.5));
    }
}

// ============================================================================
// remapvalue input divisor — line 1329 (`>` vs `>=`)
// ============================================================================
//
// `const double in_inv = std::abs(in_span) > 1e-9 ? 1.0 / in_span : 0.0;`

TEST_SUITE("remapvalue in_inv boundary") {
    TEST_CASE("inMax==inMin: in_inv=0 → t=0 → mapped=outMin") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 5.0 }, { "inputrangemax", 5.0 },
                   { "outputrangemin", 42.0 }, { "outputrangemax", 99.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 7.0); // raw=7
        // t = (7 - 5)*0 = 0 → mapped = 42.
        CHECK(out.size == doctest::Approx(42.0f).epsilon(0.05));
    }
}

// ============================================================================
// remapvalue input branches — lines 1383-1421, 1430, 1436
// ============================================================================
//
// L1383: timeofday: `(hour*3600 + min*60 + sec) / 86400.0`
// L1385/1388/1391/1399/1408/1421: `(usize)inputCP0 < info.controlpoints.size()` boundaries.
// L1412: `if (n > 1e-9) toward /= n; else toward.setZero();`
// L1430: `cp1_idx = std::min((int)info.controlpoints.size() - 1, inputCP0 + 1);` (sub mutation)
// L1436: `if (len2 > 1e-9)` boundary.

TEST_SUITE("remapvalue input branches residual") {
    TEST_CASE("input timeofday: result in [0, 1]") {
        json j = { { "name", "remapvalue" },
                   { "input", "timeofday" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 1000.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.size >= 0.0f);
        CHECK(out.size <= 1000.0f);
    }

    TEST_CASE("input controlpointvelocity in-range: reduces CP velocity vec") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[3].velocity = Eigen::Vector3d(7, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "controlpointvelocity" },
                   { "inputcomponent", "x" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 3 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size     = 0.0f;
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        // raw = 7 → t = 0.07 → mapped = 7.
        CHECK(ps[0].size == doctest::Approx(7.0f).epsilon(0.5));
    }

    TEST_CASE("input controlpointvelocity OOB CP: raw stays 0") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].velocity = Eigen::Vector3d(99, 99, 99);
        json j = { { "name", "remapvalue" },
                   { "input", "controlpointvelocity" },
                   { "inputcomponent", "x" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 5 } };  // ClampCpIndex(5)=5; cps.size=2 → OOB.
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size     = 0.0f;
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        CHECK(ps[0].size == doctest::Approx(0.0f).epsilon(0.5));
    }

    TEST_CASE("input distancetocontrolpoint: norm of (p.pos - cp.resolved)") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(60, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "distancetocontrolpoint" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size = 0.0f; base.position = Eigen::Vector3f(0, 0, 0);
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        // dist = 60 → t = 0.6 → mapped = 60.
        CHECK(ps[0].size == doctest::Approx(60.0f).epsilon(0.5));
    }

    TEST_CASE("input directiontocontrolpoint at CP (n<1e-9): toward = 0") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "directiontocontrolpoint" },
                   { "inputcomponent", "magnitude" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size = 99.0f; base.position = Eigen::Vector3f(0, 0, 0);
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        // toward.setZero() → norm=0 → mapped=0.
        CHECK(ps[0].size == doctest::Approx(0.0f).epsilon(0.5));
    }

    TEST_CASE("input positionbetweentwocontrolpoints: 0 at cp0, 1 at cp1") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(100, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "positionbetweentwocontrolpoints" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        // p at cp1 → t=1 → mapped=100
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size = 0.0f; base.position = Eigen::Vector3f(100, 0, 0);
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        CHECK(ps[0].size == doctest::Approx(100.0f).epsilon(0.5));
    }
}

// ============================================================================
// remapvalue noise input — lines 1468, 1471, 1472, 1477, 1478
// ============================================================================
//
// L1468: `for (int oct = 0; oct < transformoctaves; oct++)`
// L1471: `ppos.y() * freq` — kills mul → div.
// L1472: `ppos.z() * freq`
// L1477: `if (maxAmp < 1e-9) maxAmp = 1.0;`
// L1478: `raw = std::clamp((sum / maxAmp + 1.0) * 0.5, 0.0, 1.0);`

TEST_SUITE("remapvalue noise input residual") {
    TEST_CASE("input noise: produces value in output range") {
        json j = { { "name", "remapvalue" },
                   { "input", "noise" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "transformoctaves", 3 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.size >= 0.0f);
        CHECK(out.size <= 100.0f);
        CHECK(std::isfinite(out.size));
    }

    TEST_CASE("input noise output finite even at extreme particle position") {
        json j = { { "name", "remapvalue" },
                   { "input", "noise" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" },
                   { "transforminputscale", 1.0 },
                   { "transformoctaves", 1 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(1000, 1000, 1000);
        p.size     = 0.0f;
        auto out = runOpSingle(op, p);
        CHECK(std::isfinite(out.size));
    }
}

// ============================================================================
// remapvalue transform fn t boundary — lines 1483, 1484
// ============================================================================
//
// L1483: `if (t < 0.0) t = 0.0;`
// L1484: `if (t > 1.0) t = 1.0;`

TEST_SUITE("remapvalue t clamp boundary") {
    TEST_CASE("raw < inMin: t clamped to 0 → mapped = outMin") {
        // particle systemtime=-1, inputrangemin=0 → t<0 → clamp to 0.
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 7.0 }, { "outputrangemax", 99.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, -1.0);  // raw < 0 → t<0 → clamp.
        CHECK(out.size == doctest::Approx(7.0f).epsilon(0.05));
    }

    TEST_CASE("raw > inMax: t clamped to 1 → mapped = outMax") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 7.0 }, { "outputrangemax", 99.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 100.0);
        CHECK(out.size == doctest::Approx(99.0f).epsilon(0.5));
    }
}

// ============================================================================
// remapvalue transform fns sine/cosine/square xs scaling — lines 1504, 1506, 1509
// ============================================================================
//
// L1504: `tx = (sin(xs * 2π) + 1) * 0.5`
// L1506: `tx = (cos(xs * 2π) + 1) * 0.5`
// L1509: `tx = sin(xs * 2π) >= 0.0 ? 1.0 : 0.0`

TEST_SUITE("remapvalue transform sine cosine square xs") {
    TEST_CASE("sine peak at xs=0.25 (sin(2π*0.25)=1)") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "sine" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.25);
        // sin(2π/4)=1 → tx=1 → mapped=100.
        CHECK(out.size > 95.0f);
    }

    TEST_CASE("cosine: tx peaks at xs=0 (cos(0)=1)") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "cosine" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.0);
        // cos(0)=1 → tx=1 → 100.
        CHECK(out.size > 95.0f);
    }

    TEST_CASE("square at xs=0 (sin=0): tx=1 (kills `>=` → `>`)") {
        // sin(0) == 0; `>= 0.0` is true → tx=1.0 (high half).
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "square" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.0);
        // At xs=0: sin(0)=0; >=0 is TRUE (kills `>` mutation that would yield 0).
        CHECK(out.size == doctest::Approx(100.0f).epsilon(0.05));
    }
}

// ============================================================================
// remapvalue saw/triangle xs floor — lines 1511, 1513
// ============================================================================
//
// L1511: `tx = xs - std::floor(xs);` (saw)
// L1513: `const double f = xs - std::floor(xs); tx = abs(2*f - 1);` (triangle)
// Mutations: `-` → `+`.

TEST_SUITE("remapvalue saw/triangle floor") {
    TEST_CASE("saw at xs=0.5: tx=0.5 (kills `-` → `+`)") {
        // floor(0.5)=0 → 0.5 - 0 = 0.5.
        // Mutated: 0.5 + 0 = 0.5 (same!).  But for xs >=1 the mutation diverges.
        // xs=2.5: floor=2, 2.5-2=0.5; 2.5+2=4.5 (saw mutated).
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "saw" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 5.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        // raw=2.5, t = 2.5/5 = 0.5, xs=0.5.  Saw: 0.5 - 0 = 0.5 → mapped=50.
        auto out = runOpSingle(op, p, 0.016, 2.5);
        CHECK(out.size == doctest::Approx(50.0f).epsilon(1.0));
    }

    TEST_CASE("triangle at xs=0: tx = |2*0 - 1| = 1") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "triangle" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.0);
        // f = 0 - 0 = 0, tx = |2*0 - 1| = 1 → mapped = 100.
        CHECK(out.size == doctest::Approx(100.0f).epsilon(0.5));
    }
}

// ============================================================================
// remapvalue fbmnoise loop — lines 1532, 1537, 1538
// ============================================================================
//
// L1532: `sum += amp * algorism::PerlinNoise(xs * freq, 0.0, 0.0);`
// L1537: `if (maxAmp < 1e-9) maxAmp = 1.0;`
// L1538: `tx = std::clamp((sum / maxAmp + 1.0) * 0.5, 0.0, 1.0);`

TEST_SUITE("remapvalue fbmnoise residual") {
    TEST_CASE("fbmnoise produces finite tx in [0, 1]") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "fbmnoise" },
                   { "transformoctaves", 4 },
                   { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        for (double t : { 0.0, 0.25, 0.5, 0.75 }) {
            Particle p = makeParticle();
            p.size = 0.0f;
            auto out = runOpSingle(op, p, 0.016, t);
            CHECK(std::isfinite(out.size));
            CHECK(out.size >= 0.0f);
            CHECK(out.size <= 100.0f);
        }
    }
}

// ============================================================================
// remapvalue output controlpoint write — line 1629
// ============================================================================
//
// `if ((usize)outputCP0 < info.controlpoints.size())` — the write-back guard.

TEST_SUITE("remapvalue output cp boundary") {
    TEST_CASE("output controlpoint with outputCP < cp_size: writes back") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        cps[1].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "controlpoint" },
                   { "outputcomponent", "x" },
                   { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 50.0 },
                   { "outputcontrolpoint0", 1 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        ps.push_back(makeParticle());
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 1.0,
            .time_pass     = 0.016,
        };
        op(info);
        // raw=1 → t=1 → mapped=50.  Written into cps[1].x.
        CHECK(cps[1].resolved.x() == doctest::Approx(50.0).epsilon(0.5));
    }

    TEST_CASE("output controlpoint with outputCP > cp_size: no write (kills `<` boundary)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(11, 22, 33);
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "controlpoint" },
                   { "outputcomponent", "x" },
                   { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 99.0 }, { "outputrangemax", 99.0 },
                   { "outputcontrolpoint0", 5 } };  // OOB: ClampCpIndex(5)=5; size=2.
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        ps.push_back(makeParticle());
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.5,
            .time_pass     = 0.016,
        };
        op(info);
        // No write — cp[0] keeps its original.
        CHECK(cps[0].resolved.x() == doctest::Approx(11.0));
    }
}

// ============================================================================
// capvelocity bw branch — lines 1660, 1667
// ============================================================================
//
// L1660: `if (speed <= maxspeed || speed < 1e-9) continue;`
// L1667: `factor = 1.0 + (factor - 1.0) * blend;`

TEST_SUITE("capvelocity bw branch") {
    TEST_CASE("speed exactly == maxspeed: skipped (kills `<=` boundary)") {
        json j = { { "name", "capvelocity" }, { "maxspeed", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.velocity = Eigen::Vector3f(100, 0, 0); // speed == maxspeed
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.x() == doctest::Approx(100.0f));
    }

    TEST_CASE("capvelocity with bw window: blend lerps toward identity") {
        // bw_active over [0, 0.5] of life.  At life=0 (just spawned), blend=1.
        json j = { { "name", "capvelocity" }, { "maxspeed", 100.0 },
                   { "blendwindowstart", 0.0 }, { "blendwindowend", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.lifetime      = 1.0f;
        p.init.lifetime = 1.0f;
        p.velocity      = Eigen::Vector3f(200, 0, 0);  // speed=200, maxspeed=100, factor=0.5
        auto out = runOpSingle(op, p);
        // bw active at life=0 → blend=1 → factor = 1 + (0.5-1)*1 = 0.5 → 200*0.5=100.
        CHECK(out.velocity.x() == doctest::Approx(100.0f).epsilon(0.1));
    }
}

// ============================================================================
// maintaindistancetocontrolpoint — lines 1687, 1694, 1701
// ============================================================================
//
// L1687: `if ((usize)controlpoint >= info.controlpoints.size()) return;`
// L1694: `if (dist < 1e-6) continue;`
// L1701: `pull = (distance - dist) * variablestrength * blend;`

TEST_SUITE("maintaindistancetocontrolpoint residual") {
    TEST_CASE("controlpoint OOB: early return (kills `>=` → `>`)") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "maintaindistancetocontrolpoint" },
                   { "controlpoint", 5 },  // ClampCpIndex(5)=5, cps.size()=2.
                   { "distance", 100.0 },
                   { "variablestrength", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.position.x() == doctest::Approx(50.0f));
    }

    TEST_CASE("particle exactly at CP (dist=0): skipped (kills `<` boundary)") {
        json j = { { "name", "maintaindistancetocontrolpoint" },
                   { "controlpoint", 0 },
                   { "distance", 100.0 },
                   { "variablestrength", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 0, 0);  // at CP
        auto out = runOpSingle(op, p);
        // No motion applied.
        CHECK(out.position.x() == doctest::Approx(0.0f));
    }

    TEST_CASE("variablestrength > 0: spring pull toward target distance") {
        json j = { { "name", "maintaindistancetocontrolpoint" },
                   { "controlpoint", 0 },
                   { "distance", 100.0 },
                   { "variablestrength", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);  // distance=50, target=100, pull = 50.
        auto out = runOpSingle(op, p, 0.1);
        // accel = 50 * (1,0,0) for dt=0.1 → velocity gain ~5.
        CHECK(out.velocity.x() > 0.0f);
    }
}

// ============================================================================
// collisionplane / collisionsphere — lines 1720, 1724, 1726, 1731, 1750, 1756
// ============================================================================
//
// L1720: `if (normal_raw.norm() < 1e-9) normal_raw = Vector3d(0, 1, 0);`
// L1724: `if ((usize)controlpoint >= info.controlpoints.size()) return;`
// L1726: `const double plane_d = d_signed - normal.dot(cp) - distance;`
// L1731: `if (sd >= 0.0) continue;`
// L1750: `if ((usize)controlpoint >= info.controlpoints.size()) return;`
// L1756: `if (dist >= radius || dist < 1e-9) continue;`

TEST_SUITE("collisionplane/sphere residual") {
    TEST_CASE("collisionplane: zero normal falls back to (0,1,0)") {
        json j = { { "name", "collisionplane" },
                   { "plane", "0 0 0 0" },  // zero normal_raw
                   { "controlpoint", 0 },
                   { "distance", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, -10, 0);  // below default normal (0,1,0).
        p.velocity = Eigen::Vector3f(0, -5, 0);
        auto out = runOpSingle(op, p);
        // Snapped to plane (y=0); reflected velocity to +y.
        CHECK(out.position.y() == doctest::Approx(0.0f).epsilon(0.05));
        CHECK(out.velocity.y() > 0.0f);
    }

    TEST_CASE("collisionplane: sd >= 0 (above plane): skipped (kills `>=` boundary)") {
        json j = { { "name", "collisionplane" },
                   { "plane", "0 1 0 0" },
                   { "controlpoint", 0 },
                   { "distance", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 5, 0);  // above plane.
        p.velocity = Eigen::Vector3f(0, 1, 0);
        auto out = runOpSingle(op, p);
        // Untouched.
        CHECK(out.position.y() == doctest::Approx(5.0f));
    }

    TEST_CASE("collisionplane controlpoint OOB: early return") {
        std::vector<ParticleControlpoint> cps(1, ParticleControlpoint {});
        json j = { { "name", "collisionplane" },
                   { "plane", "0 1 0 0" },
                   { "controlpoint", 5 },  // OOB
                   { "distance", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, -10, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.position.y() == doctest::Approx(-10.0f));
    }

    TEST_CASE("collisionsphere: dist exactly == radius: skipped (kills `>=` boundary)") {
        json j = { { "name", "collisionsphere" },
                   { "controlpoint", 0 },
                   { "radius", 100.0 },
                   { "origin", "0 0 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(100, 0, 0);  // dist == radius
        auto out = runOpSingle(op, p);
        CHECK(out.position.x() == doctest::Approx(100.0f));
    }
}

// ============================================================================
// collisionquad — lines 1800, 1804, 1806, 1813, 1815, 1827, 1834
// ============================================================================
//
// L1800: `if (n_raw.norm() < 1e-9) n_raw = Vector3d(0, 1, 0);`
// L1804: `if (fwd_orth.norm() < 1e-9) ...`
// L1806: `(std::abs(n.x()) < 0.9) ? Vector3d(1, 0, 0).cross(n) : Vector3d(0, 1, 0).cross(n);`
// L1813: `const double half_v = size[1] * 0.5;`
// L1815: `if ((usize)controlpoint >= info.controlpoints.size()) return;`
// L1827: `const bool crossed = (sd >= 0.0) != (prev_sd >= 0.0);`
// L1834: `if (std::abs(u) > half_u || std::abs(v) > half_v) continue;`

TEST_SUITE("collisionquad residual") {
    TEST_CASE("zero plane falls back to default (0,1,0)") {
        json j = { { "name", "collisionquad" },
                   { "plane", "0 0 0" },
                   { "forward", "1 0 0" },
                   { "origin", "0 0 0" },
                   { "size", "100 100" },
                   { "controlpoint", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 1, 0);   // above default plane (0,1,0).
        p.velocity = Eigen::Vector3f(0, -10, 0); // moving down.
        auto out = runOpSingle(op, p, 0.5);
        // Crossed plane (sd=1, prev_sd=1-(-10)*0.5=6 — both >=0; no crossing).
        // Wait: prev_sd = sd - vel.dot(n)*dt = 1 - (-10)*0.5 = 1 + 5 = 6.
        // Both 1>=0 and 6>=0 → not crossed → skipped.  Untouched.
        CHECK(out.position.y() == doctest::Approx(1.0f).epsilon(0.05));
    }

    TEST_CASE("collisionquad outside half-bounds: not reflected (kills `>` half_u)") {
        json j = { { "name", "collisionquad" },
                   { "plane", "0 1 0" },
                   { "forward", "1 0 0" },
                   { "origin", "0 0 0" },
                   { "size", "10 10" },  // half = 5 each
                   { "controlpoint", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0.0001f, 0);  // u=50 way outside half_u=5
        p.velocity = Eigen::Vector3f(0, -1, 0);
        auto out = runOpSingle(op, p, 0.5);
        // |u|=50 > 5 → skip.  No reflect.
        CHECK(out.velocity.y() == doctest::Approx(-1.0f).epsilon(0.001));
    }

    TEST_CASE("collisionquad inside bounds: reflects velocity") {
        json j = { { "name", "collisionquad" },
                   { "plane", "0 1 0" },
                   { "forward", "1 0 0" },
                   { "origin", "0 0 0" },
                   { "size", "100 100" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, -1, 0);
        p.velocity = Eigen::Vector3f(0, -10, 0);
        // sd = -1, prev_sd = -1 - (-10)*0.5 = -1 + 5 = 4.  Both differ in sign → crossed.
        auto out = runOpSingle(op, p, 0.5);
        CHECK(out.velocity.y() > 0.0f);
    }
}

// ============================================================================
// collisionbox — lines 1869, 1878, 1880, 1884, 1886, 1887
// ============================================================================
//
// L1869: `if ((usize)controlpoint >= info.controlpoints.size()) return;`
// L1878: `if (local[a] > h)` — kills `>` → `>=`.
// L1880: `if (p.velocity[a] > 0)` — kills `>` → `>=` for reflection.
// L1884: `} else if (local[a] < -h)` — kills `<` boundary.
// L1886: `if (p.velocity[a] < 0)`
// L1887: reflection multiplier `*restitution`.

TEST_SUITE("collisionbox residual") {
    TEST_CASE("particle exactly at +halfsize boundary: skipped (kills `>` → `>=`)") {
        // local == halfsize exactly → not > h → no clamp.
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(10, 0, 0);  // x exactly at halfsize.
        p.velocity = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingle(op, p);
        // No clamp, velocity unchanged.
        CHECK(out.velocity.x() == doctest::Approx(50.0f));
    }

    TEST_CASE("particle past +halfsize moving +x reflects to -x") {
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(20, 0, 0);  // out of bounds.
        p.velocity = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.x() == doctest::Approx(-50.0f).epsilon(0.05));
        CHECK(out.position.x() == doctest::Approx(10.0f).epsilon(0.05));
    }

    TEST_CASE("particle past -halfsize moving -x reflects to +x") {
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(-20, 0, 0);
        p.velocity = Eigen::Vector3f(-50, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.x() == doctest::Approx(50.0f).epsilon(0.05));
        CHECK(out.position.x() == doctest::Approx(-10.0f).epsilon(0.05));
    }

    TEST_CASE("particle past +halfsize but velocity already -x: no reflect (kills `>0`)") {
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(20, 0, 0);
        p.velocity = Eigen::Vector3f(-30, 0, 0);  // already moving back
        auto out = runOpSingle(op, p);
        // Position clamped to 10, but velocity NOT reflected.
        CHECK(out.position.x() == doctest::Approx(10.0f).epsilon(0.05));
        CHECK(out.velocity.x() == doctest::Approx(-30.0f).epsilon(0.05));
    }

    TEST_CASE("restitution<1 dampens reflection (kills `* restitution` → `/`)") {
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 0.5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(20, 0, 0);
        p.velocity = Eigen::Vector3f(100, 0, 0);
        auto out = runOpSingle(op, p);
        // Reflected: -100 * 0.5 = -50.  Mutated /: -100 / 0.5 = -200.
        CHECK(out.velocity.x() == doctest::Approx(-50.0f).epsilon(0.05));
    }
}

// ============================================================================
// boids — lines 1932, 1936, 1938, 1951
// ============================================================================
//
// L1932: `if (sd >= n2) continue;`
// L1936: `if (sd < s2 && sd > 1e-12)`
// L1938: `sumSep -= (d / dist) * (separationthreshold - dist);`
// L1951: `if (speed > maxspeed && speed > 1e-9)`

TEST_SUITE("boids residual") {
    TEST_CASE("boids: no neighbours → no acceleration applied") {
        json j = { { "name", "boids" },
                   { "neighborthreshold", 0.5 },
                   { "separationthreshold", 0.5 },
                   { "separationfactor", 1.0 },
                   { "alignmentfactor", 1.0 },
                   { "cohesionfactor", 1.0 },
                   { "maxspeed", 1000.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle a = makeParticle();
        a.position = Eigen::Vector3f(0, 0, 0);
        a.velocity = Eigen::Vector3f(10, 0, 0);
        Particle b = makeParticle();
        b.position = Eigen::Vector3f(100, 0, 0);  // far outside threshold
        b.velocity = Eigen::Vector3f(0, 0, 0);
        ps.push_back(a);
        ps.push_back(b);
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        // First particle's velocity should be unchanged.
        CHECK(ps[0].velocity.x() == doctest::Approx(10.0f).epsilon(0.05));
    }

    TEST_CASE("boids speed > maxspeed → clamped to maxspeed") {
        json j = { { "name", "boids" },
                   { "neighborthreshold", 0.0 },  // no neighbours
                   { "separationthreshold", 0.0 },
                   { "separationfactor", 0.0 },
                   { "alignmentfactor", 0.0 },
                   { "cohesionfactor", 0.0 },
                   { "maxspeed", 10.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle a = makeParticle();
        a.position = Eigen::Vector3f(0, 0, 0);
        a.velocity = Eigen::Vector3f(50, 0, 0);  // > maxspeed=10
        ps.push_back(a);
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        CHECK(ps[0].velocity.norm() == doctest::Approx(10.0f).epsilon(0.05));
    }

    TEST_CASE("boids speed exactly == maxspeed: no clamp (kills `>` boundary)") {
        json j = { { "name", "boids" },
                   { "neighborthreshold", 0.0 },
                   { "separationthreshold", 0.0 },
                   { "separationfactor", 0.0 },
                   { "alignmentfactor", 0.0 },
                   { "cohesionfactor", 0.0 },
                   { "maxspeed", 10.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle a = makeParticle();
        a.position = Eigen::Vector3f(0, 0, 0);
        a.velocity = Eigen::Vector3f(10, 0, 0);   // exactly maxspeed
        ps.push_back(a);
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        // No clamp — speed unchanged.
        CHECK(ps[0].velocity.x() == doctest::Approx(10.0f).epsilon(0.05));
    }
}

// ============================================================================
// inheritvaluefromevent — lines 1986, 1988, 1995, 1997, 2001
// ============================================================================
//
// L1986: `if (f <= 0.0) continue;`
// L1988-2003: `result = (current * (1-f) + parent * f).cast<...>();` for color/size/etc.
//
// `info.instance == nullptr` is the common path — operator no-ops.  These
// mutations are hard to reach without a proper ParticleInstance + parent
// particle setup.  We pin the no-instance branch.

TEST_SUITE("inheritvaluefromevent residual") {
    TEST_CASE("info.instance == nullptr: operator is no-op (kills `f <= 0` branch direction)") {
        json j = { { "name", "inheritvaluefromevent" },
                   { "input", "color" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.color = Eigen::Vector3f(0.7f, 0.3f, 0.1f);
        auto out = runOpSingle(op, p);
        // No instance/parent → no change.
        CHECK(out.color.x() == doctest::Approx(0.7f).epsilon(0.001));
        CHECK(out.color.y() == doctest::Approx(0.3f).epsilon(0.001));
        CHECK(out.color.z() == doctest::Approx(0.1f).epsilon(0.001));
    }

    TEST_CASE("inheritvaluefromevent unrecognised input: no change") {
        json j = { { "name", "inheritvaluefromevent" },
                   { "input", "unknownInput" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.alpha = 0.5f;
        auto out = runOpSingle(op, p);
        CHECK(out.alpha == doctest::Approx(0.5f).epsilon(0.001));
    }
}

// ============================================================================
// controlpointattract — lines 2027, 2028
// ============================================================================
//
// L2027: `if (distance >= c.threshold) continue;`
// L2028: `if (distance < 1e-9) continue;`

TEST_SUITE("controlpointattract residual") {
    TEST_CASE("distance == threshold: skipped (kills `>=` boundary)") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(0, 0, 0);
        json j = { { "name", "controlpointattract" },
                   { "controlpoint", 0 },
                   { "scale", 100.0 },
                   { "threshold", 50.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);  // exactly at threshold
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.velocity.norm() == doctest::Approx(0.0f).epsilon(0.05));
    }

    TEST_CASE("distance exactly at CP (== 0): skipped (kills `< 1e-9`)") {
        json j = { { "name", "controlpointattract" },
                   { "controlpoint", 0 },
                   { "scale", 100.0 }, { "threshold", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 0, 0);  // at CP
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.norm() == doctest::Approx(0.0f).epsilon(0.05));
    }
}

// ============================================================================
// genParticleEmittOp periodic phaseDur computations — lines 2088, 2090, 2091
// ============================================================================
//
// L2088: `double phaseDur = maxDur > 0 ? Random::get((double)minDur, (double)maxDur) : 0.1;`
// L2090: `double totalCycle = (minDur + maxDur) * 0.5 + (minDelay + maxDelay) * 0.5;`
// L2091: `double timer = totalCycle > 0 ? Random::get(0.0, totalCycle) : 0.0;`

TEST_SUITE("genParticleEmittOp phaseDur boundary") {
    TEST_CASE("maxperiodicduration=0 with maxperiodicdelay>0: phaseDur defaults to 0.1") {
        // Hits the `maxDur > 0 ? ... : 0.1` branch's else.
        wpscene::Emitter e;
        e.name        = "boxrandom";
        e.rate        = 100.0f;
        e.directions  = { 1, 1, 1 };
        e.distancemin = { 0, 0, 0 };
        e.distancemax = { 10, 10, 10 };
        e.origin      = { 0, 0, 0 };
        e.minperiodicdelay     = 0.5f;
        e.maxperiodicdelay     = 0.5f;
        e.minperiodicduration  = 0.0f;
        e.maxperiodicduration  = 0.0f;
        e.duration             = 0.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        // Just verify the op runs without hanging.
        for (int i = 0; i < 20; i++) op(ps, inis, 1000, 0.05);
        // No crash.
        CHECK(true);
    }

    TEST_CASE("totalCycle=0: timer initialized to 0 (kills `> 0` branch)") {
        // All four periodic min/max set to 0.001 — totalCycle = 0.001 (small but >0).
        wpscene::Emitter e;
        e.name        = "boxrandom";
        e.rate        = 100.0f;
        e.directions  = { 1, 1, 1 };
        e.distancemin = { 0, 0, 0 };
        e.distancemax = { 10, 10, 10 };
        e.origin      = { 0, 0, 0 };
        e.minperiodicdelay     = 0.001f;
        e.maxperiodicdelay     = 0.001f;
        e.minperiodicduration  = 0.001f;
        e.maxperiodicduration  = 0.001f;
        e.duration             = 0.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        for (int i = 0; i < 5; i++) op(ps, inis, 1000, 0.01);
        CHECK(true);
    }
}

// ============================================================================
// genParticleEmittOp periodic active flip — lines 2101, 2105, 2109, 2112,
// 2114, 2116, 2122, 2128, 2131, 2132, 2147
// ============================================================================
//
// L2101: `while (timer >= phaseDur)` — phase boundary.
// L2105: `phaseDur = maxDur > 0 ? Random::get(min, max) : 0.1;` (active branch)
// L2109: `phaseDur = maxDelay > 0 ? Random::get(min, max) : 0.1;` (delay branch)
// L2112: `if (active && (maxPer == 0 || emittedCount < maxPer))`
// L2114: `if (maxPer > 0)`
// L2116: `aliveBefore++` post-inc.
// L2122: `if (justActivated && maxPer > 0 && batch_size <= 1)`
// L2128: `if (maxPer > 0)` (post-burst alive count).
// L2131: `if (LifetimeOk(p)) aliveAfter++`.
// L2132: `if (aliveAfter > aliveBefore) emittedCount += diff`.
// L2147: `if (elapsed <= duration)` — duration wrapper.

TEST_SUITE("genParticleEmittOp periodic deeper") {
    auto make_emitter = [](float minD, float maxD, float minDur, float maxDur, u32 maxPer) {
        wpscene::Emitter e;
        e.name        = "boxrandom";
        e.rate        = 100.0f;
        e.directions  = { 1, 1, 1 };
        e.distancemin = { 0, 0, 0 };
        e.distancemax = { 10, 10, 10 };
        e.origin      = { 0, 0, 0 };
        e.minperiodicdelay     = minD;
        e.maxperiodicdelay     = maxD;
        e.minperiodicduration  = minDur;
        e.maxperiodicduration  = maxDur;
        e.maxtoemitperperiod   = maxPer;
        e.duration             = 0.0f;
        return e;
    };

    TEST_CASE("periodic with maxperiodicdelay=0 hits delay branch's 0.1 fallback") {
        // maxperiodicduration > 0 → hasPeriodic.  maxperiodicdelay=0 → delay defaults 0.1.
        auto e = make_emitter(0.0f, 0.0f, 0.5f, 0.5f, 0);
        e.rate = 100.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        for (int i = 0; i < 30; i++) op(ps, inis, 1000, 0.05);
        CHECK(ps.size() > 0);
    }

    TEST_CASE("periodic with batch_size>1 disables justActivated burst") {
        auto e = make_emitter(0.5f, 0.5f, 0.5f, 0.5f, 5);
        e.rate = 1000.0f;
        // batch_size=4 > 1 → justActivated burst short-circuit.
        auto op = WPParticleParser::genParticleEmittOp(e, false, 4, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        for (int i = 0; i < 5; i++) op(ps, inis, 1000, 0.1);
        CHECK(ps.size() <= 1000);
    }

    TEST_CASE("duration wrapper: elapsed exactly == duration still emits (boundary `<=`)") {
        auto e = make_emitter(0, 0, 0, 0, 0);
        e.duration = 0.1f;
        e.rate     = 100.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        op(ps, inis, 1000, 0.1);  // elapsed = 0.1 ≤ 0.1 → emit.
        CHECK(ps.size() > 0);
    }

    TEST_CASE("hasPeriodic with only maxperiodicdelay set (no duration)") {
        // hasPeriodic = (maxDur > 0) || (maxDelay > 0); set delay only.
        auto e = make_emitter(0.0f, 0.5f, 0.0f, 0.0f, 0);
        e.rate = 100.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        // Just run; verify no crash and finite emission count.
        for (int i = 0; i < 20; i++) op(ps, inis, 1000, 0.05);
        CHECK(ps.size() <= 1000);
    }

    TEST_CASE("maxtoemitperperiod=0 disables emit cap (kills `maxPer == 0` short-circuit)") {
        // maxPer=0 → emit cap disabled — emission goes uncapped during active.
        // Use a short delay so we land in the active phase within a few op() ticks.
        auto e = make_emitter(0.0f, 0.0f, 1.0f, 1.0f, 0);
        e.rate = 1000.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        // 5 ticks × 0.1s = 0.5s; with delay=0 we should be in the duration window
        // and have emitted SOMEthing.
        for (int i = 0; i < 5; i++) op(ps, inis, 1000, 0.1);
        CHECK(ps.size() > 0);
    }

    TEST_CASE("maxtoemitperperiod>0 applies cap during active phase") {
        auto e = make_emitter(0.5f, 0.5f, 0.5f, 0.5f, 2);
        e.rate = 1000.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        // Fire many small ticks while active.  Cap=2 should stop emission.
        for (int i = 0; i < 10; i++) op(ps, inis, 1000, 0.01);
        CHECK(ps.size() <= 50);  // far less than 1000 it would emit uncapped.
    }

    TEST_CASE("burst-at-start respects maxPer (no `effectiveTime=1000` overshoot)") {
        // Regression: the justActivated burst path used to set effectiveTime=1000.0
        // unconditionally, which made baseOp emit up to maxcount particles ignoring
        // maxPer entirely (the post-emit cap can only block subsequent ticks, not
        // undo the overshoot).  With totalCycle=1.0 the random initial timer hit
        // the flip range ~10% of runs, producing a flaky failure.
        //
        // Run >2× totalCycle so a flip-into-active is guaranteed regardless of the
        // initial random timer phase, and verify the cap holds across multiple
        // active periods.
        auto e = make_emitter(0.5f, 0.5f, 0.5f, 0.5f, 2);
        e.rate = 1000.0f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        for (int i = 0; i < 200; i++) op(ps, inis, 1000, 0.01);
        // Each active period emits ~rate*timepass=10 before cap triggers; over 2s
        // that's ~3 active flips × 10 ≤ 100 particles.  Old code: 1000 (pool-full).
        CHECK(ps.size() <= 100);
    }

}

// ============================================================================
// SECOND PASS — additional residual targets
// ============================================================================

// FrequencyValue::ReadFromJson — line 746 (`==` vs `!=`)
TEST_SUITE("FrequencyValue ReadFromJson eq boundary") {
    TEST_CASE("frequencymax==0 swap collapses to frequencymin") {
        json j = { { "name", "oscillatealpha" },
                   { "frequencymin", 0.0 }, { "frequencymax", 0.0 },
                   { "scalemin", 0.5 }, { "scalemax", 0.5 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.alpha    = 1.0f;
        auto out   = runOpSingle(op, p);
        // freq=0 → w=0 → cos(0)=1 → tx=1 → scale = lerp(1, 0.5, 0.5) = 0.5.
        CHECK(out.alpha == doctest::Approx(0.5f).epsilon(0.05));
    }
}

// FrequencyValue cos at LifetimePassed=0 — lines 769-777
TEST_SUITE("FrequencyValue cos at zero time") {
    TEST_CASE("oscillatealpha at lifetime fresh: scalemax (cos(phase)=1 broadly)") {
        json j = { { "name", "oscillatealpha" },
                   { "frequencymin", 5.0 }, { "frequencymax", 5.0 },
                   { "scalemin", 0.2 }, { "scalemax", 0.8 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.alpha         = 1.0f;
        p.lifetime      = 1.0f;
        p.init.lifetime = 1.0f;
        auto out = runOpSingle(op, p);
        // Random phase consumed, but result is in [scalemin, scalemax].
        CHECK(out.alpha >= 0.0f);
        CHECK(out.alpha <= 1.0f);
    }
}

// vortex_v2 zone boundaries deeper — lines 1129, 1131, 1134
TEST_SUITE("vortex_v2 zone boundary deeper") {
    TEST_CASE("vortex_v2 distance > distanceouter triggers outer speed") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 50.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(150, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        // direct = axis × radial = (0,0,1)×(150,0,0) = (0,150,0); normalised = (0,1,0).
        // speed=200 → accel +y by 200.
        CHECK(out.velocity.y() == doctest::Approx(200.0f).epsilon(0.5));
    }

    TEST_CASE("vortex_v2 distance == distanceinner: not strictly less, falls to middle zone") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 50.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 100.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(100, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        // dis_mid=100.1; t = (100-100)/100.1 = 0; lerp(0, 50, 200) = 50.
        CHECK(out.velocity.y() == doctest::Approx(50.0f).epsilon(0.5));
    }
}

// alphafade fadeintime upper boundary
TEST_SUITE("alphafade fadeintime upper boundary") {
    TEST_CASE("age01 > fadeintime: no fade-in attenuation (with no fade-out)") {
        // fadeintime=0.3, fadeouttime=1.0 → no fade-out, fade-in ends at 0.3.
        // age01=0.5 > 0.3 → plateau → alpha unchanged.
        json j = { { "name", "alphafade" },
                   { "fadeintime", 0.3 }, { "fadeouttime", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.init.lifetime = 1.0f;
        p.lifetime      = 0.5f;   // age01 = 0.5 > fadeintime = 0.3
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.alpha == doctest::Approx(1.0f).epsilon(0.001));
    }

    TEST_CASE("age01 < fadeintime: fade-in attenuation age01/fadeintime") {
        // fadeintime=0.5, fadeouttime=1.0 (no fade-out).
        // age01=0.25 < 0.5 → alpha = 0.25/0.5 = 0.5.
        json j = { { "name", "alphafade" },
                   { "fadeintime", 0.5 }, { "fadeouttime", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.init.lifetime = 1.0f;
        p.lifetime      = 0.75f;   // age01 = 1 - 0.75 = 0.25
        p.alpha         = 1.0f;
        auto out = runOpSingle(op, p);
        CHECK(out.alpha == doctest::Approx(0.5f).epsilon(0.05));
    }
}

// oscillateposition mask boundaries — line 1027
TEST_SUITE("oscillateposition mask deeper") {
    TEST_CASE("mask=1 0 0: only x processed") {
        json j = { { "name", "oscillateposition" },
                   { "frequencymin", 1.0 }, { "frequencymax", 1.0 },
                   { "scalemin", 1.0 }, { "scalemax", 1.0 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 },
                   { "mask", "1 0 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 5, 7);
        ps.push_back(p);
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.05,
        };
        op(info);
        CHECK(ps[0].position.y() == doctest::Approx(5.0f));
        CHECK(ps[0].position.z() == doctest::Approx(7.0f));
    }

    TEST_CASE("mask=0.005 below threshold: all skipped") {
        json j = { { "name", "oscillateposition" },
                   { "frequencymin", 1.0 }, { "frequencymax", 1.0 },
                   { "scalemin", 100.0 }, { "scalemax", 100.0 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 },
                   { "mask", "0.005 0.005 0.005" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(7, 7, 7);
        ps.push_back(p);
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.5,
        };
        op(info);
        CHECK(ps[0].position.x() == doctest::Approx(7.0f));
        CHECK(ps[0].position.y() == doctest::Approx(7.0f));
        CHECK(ps[0].position.z() == doctest::Approx(7.0f));
    }
}

// remapvalue set operation no-blend — line 1566
TEST_SUITE("remapvalue set no-blend") {
    TEST_CASE("set: pure replace (current irrelevant)") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 42.0 }, { "outputrangemax", 42.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 7.0f;
        auto out = runOpSingle(op, p, 0.016, 0.5);
        CHECK(out.size == doctest::Approx(42.0f).epsilon(0.05));
    }
}

// remapvalue inputCP boundaries — lines 1391, 1399, 1408, 1421
TEST_SUITE("remapvalue inputCP boundaries deeper") {
    TEST_CASE("input distancetocontrolpoint OOB inputCP returns 0") {
        std::vector<ParticleControlpoint> cps(2, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(99, 99, 99);
        json j = { { "name", "remapvalue" },
                   { "input", "distancetocontrolpoint" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size = 99.0f; base.position = Eigen::Vector3f(0, 0, 0);
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        CHECK(ps[0].size == doctest::Approx(0.0f).epsilon(0.5));
    }

    TEST_CASE("input deltatocontrolpoint x: position.x - cp.x") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(20, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "deltatocontrolpoint" },
                   { "inputcomponent", "x" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size = 0.0f; base.position = Eigen::Vector3f(50, 0, 0);
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        CHECK(ps[0].size == doctest::Approx(30.0f).epsilon(0.5));
    }

    TEST_CASE("input directiontocontrolpoint x: normalised then reduces") {
        std::vector<ParticleControlpoint> cps(8, ParticleControlpoint {});
        cps[0].resolved = Eigen::Vector3d(10, 0, 0);
        json j = { { "name", "remapvalue" },
                   { "input", "directiontocontrolpoint" },
                   { "inputcomponent", "x" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 },
                   { "inputcontrolpoint0", 0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle base = makeParticle();
        base.size = 0.0f; base.position = Eigen::Vector3f(0, 0, 0);
        ps.push_back(base);
        ParticleInfo info {
            .particles     = std::span<Particle>(ps),
            .controlpoints = std::span<ParticleControlpoint>(cps.data(), cps.size()),
            .time          = 0.0,
            .time_pass     = 0.016,
        };
        op(info);
        // toward = (10,0,0); norm=10; normalised = (1,0,0); x=1 → t=1 → mapped=100.
        CHECK(ps[0].size == doctest::Approx(100.0f).epsilon(0.5));
    }
}

// remapvalue noise position — lines 1471-1472
TEST_SUITE("remapvalue noise input deeper") {
    TEST_CASE("noise at p=(100,100,100) octaves=1: finite output") {
        json j = { { "name", "remapvalue" },
                   { "input", "noise" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "transformoctaves", 1 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size     = 0.0f;
        p.position = Eigen::Vector3f(100, 100, 100);
        auto out = runOpSingle(op, p, 0.016, 0.0);
        CHECK(std::isfinite(out.size));
        CHECK(out.size >= 0.0f);
        CHECK(out.size <= 100.0f);
    }
}

// remapvalue fbmnoise xs zero — line 1532
TEST_SUITE("remapvalue fbmnoise xs zero anchor") {
    TEST_CASE("fbmnoise xs=0: tx≈0.5 → mapped≈50") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "fbmnoise" },
                   { "transformoctaves", 4 },
                   { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.0);
        CHECK(out.size == doctest::Approx(50.0f).epsilon(5.0));
    }
}

// capvelocity bw factor — line 1660
TEST_SUITE("capvelocity bw factor deeper") {
    TEST_CASE("speed < 1e-9 (tiny): skipped") {
        json j = { { "name", "capvelocity" }, { "maxspeed", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.velocity = Eigen::Vector3f(1e-11f, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.norm() < 1e-9f);
    }
}

// maintaindistancetocontrolpoint pull formula — line 1701
TEST_SUITE("maintaindistancetocontrolpoint pull formula deeper") {
    TEST_CASE("variablestrength=2: pull doubles vs strength=1") {
        json j1 = { { "name", "maintaindistancetocontrolpoint" },
                    { "controlpoint", 0 },
                    { "distance", 100.0 }, { "variablestrength", 1.0 } };
        json j2 = { { "name", "maintaindistancetocontrolpoint" },
                    { "controlpoint", 0 },
                    { "distance", 100.0 }, { "variablestrength", 2.0 } };
        auto op1 = WPParticleParser::genParticleOperatorOp(j1, emptyOverride());
        auto op2 = WPParticleParser::genParticleOperatorOp(j2, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);
        auto out1 = runOpSingle(op1, p, 0.1);
        Particle q = makeParticle();
        q.position = Eigen::Vector3f(50, 0, 0);
        auto out2 = runOpSingle(op2, q, 0.1);
        CHECK(out2.velocity.x() > out1.velocity.x() * 1.5f);
    }
}

// collisionplane d_signed — line 1726
TEST_SUITE("collisionplane d_signed sign") {
    TEST_CASE("collisionplane (0,1,0,5) places plane at y=-5") {
        json j = { { "name", "collisionplane" },
                   { "plane", "0 1 0 5" },
                   { "controlpoint", 0 },
                   { "distance", 0.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, -10, 0);
        p.velocity = Eigen::Vector3f(0, -1, 0);
        auto out = runOpSingle(op, p);
        // Snap: ppos - sd*normal = (0,-10,0) - (-5)*(0,1,0) = (0,-5,0).
        CHECK(out.position.y() == doctest::Approx(-5.0f).epsilon(0.05));
    }
}

// collisionsphere boundary — lines 1750, 1756
TEST_SUITE("collisionsphere boundary deeper") {
    TEST_CASE("controlpoint OOB: early return") {
        std::vector<ParticleControlpoint> cps(1, ParticleControlpoint {});
        json j = { { "name", "collisionsphere" },
                   { "controlpoint", 5 },
                   { "radius", 100.0 },
                   { "origin", "0 0 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()));
        CHECK(out.position.x() == doctest::Approx(50.0f));
    }

    TEST_CASE("dist < 1e-9 (at center): skipped") {
        json j = { { "name", "collisionsphere" },
                   { "controlpoint", 0 },
                   { "radius", 100.0 },
                   { "origin", "0 0 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.position.x() == doctest::Approx(0.0f));
    }
}

// collisionquad — lines 1813, 1815, 1827, 1834
TEST_SUITE("collisionquad bounds deeper") {
    TEST_CASE("particle outside half_v but inside half_u: skipped") {
        json j = { { "name", "collisionquad" },
                   { "plane", "0 1 0" },
                   { "forward", "1 0 0" },
                   { "origin", "0 0 0" },
                   { "size", "100 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, -1, 50);
        p.velocity = Eigen::Vector3f(0, -10, 0);
        auto out = runOpSingle(op, p, 0.5);
        CHECK(out.velocity.y() == doctest::Approx(-10.0f).epsilon(0.001));
    }

    TEST_CASE("collisionquad controlpoint OOB: early return") {
        std::vector<ParticleControlpoint> cps(1, ParticleControlpoint {});
        json j = { { "name", "collisionquad" },
                   { "plane", "0 1 0" },
                   { "forward", "1 0 0" },
                   { "size", "100 100" },
                   { "controlpoint", 5 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(0, -1, 0);
        p.velocity = Eigen::Vector3f(0, -10, 0);
        auto out = runOpSingleCps(
            op, p, std::span<ParticleControlpoint>(cps.data(), cps.size()), 0.5);
        CHECK(out.velocity.y() == doctest::Approx(-10.0f).epsilon(0.001));
    }
}

// collisionbox -halfsize — lines 1884, 1886
TEST_SUITE("collisionbox -halfsize boundary") {
    TEST_CASE("particle exactly at -halfsize: not clamped (boundary `<`)") {
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(-10, 0, 0);
        p.velocity = Eigen::Vector3f(-50, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.velocity.x() == doctest::Approx(-50.0f));
    }

    TEST_CASE("particle past -halfsize but vel +x: no reflect") {
        json j = { { "name", "collisionbox" },
                   { "halfsize", "10 10 10" },
                   { "controlpoint", 0 },
                   { "restitution", 1.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(-20, 0, 0);
        p.velocity = Eigen::Vector3f(30, 0, 0);
        auto out = runOpSingle(op, p);
        CHECK(out.position.x() == doctest::Approx(-10.0f).epsilon(0.05));
        CHECK(out.velocity.x() == doctest::Approx(30.0f).epsilon(0.05));
    }
}

// genParticleEmittOp duration boundary — line 2147
TEST_SUITE("genParticleEmittOp duration boundary deeper") {
    TEST_CASE("elapsed > duration: no emit") {
        wpscene::Emitter e;
        e.name        = "boxrandom";
        e.rate        = 100.0f;
        e.directions  = { 1, 1, 1 };
        e.distancemin = { 0, 0, 0 };
        e.distancemax = { 10, 10, 10 };
        e.duration    = 0.5f;
        auto op = WPParticleParser::genParticleEmittOp(e, false, 1, 0.0f);
        std::vector<Particle> ps;
        std::vector<ParticleInitOp> inis;
        op(ps, inis, 1000, 0.6); // elapsed=0.6 > 0.5 → skip.
        CHECK(ps.empty());
    }
}

// remapvalue transformoctaves clamp — line 1257
TEST_SUITE("remapvalue transformoctaves bounds residual") {
    TEST_CASE("transformoctaves=0 clamped to 1: noise still finite") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "fbmnoise" },
                   { "transformoctaves", 0 },
                   { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.5);
        CHECK(std::isfinite(out.size));
    }
}

// reducemovementnearcontrolpoint inner boundary — line 1213
TEST_SUITE("reducemovementnearcontrolpoint inner boundary") {
    TEST_CASE("dist == distanceinner: full inner reduction") {
        json j = { { "name", "reducemovementnearcontrolpoint" },
                   { "controlpoint", 0 },
                   { "distanceinner", 50.0 }, { "distanceouter", 200.0 },
                   { "reductioninner", 5.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(50, 0, 0);
        p.velocity = Eigen::Vector3f(100, 0, 0);
        auto out = runOpSingle(op, p, 0.1);
        // factor = 1/(1+5*0.1) ≈ 0.667.
        CHECK(out.velocity.x() == doctest::Approx(66.67f).epsilon(2.0));
    }
}

// remapvalue input speed/maxlifetime — input dispatch
TEST_SUITE("remapvalue input speed dispatch") {
    TEST_CASE("input speed: norm of velocity") {
        json j = { { "name", "remapvalue" },
                   { "input", "speed" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 100.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size     = 0.0f;
        p.velocity = Eigen::Vector3f(60, 80, 0);  // speed = 100
        auto out   = runOpSingle(op, p);
        CHECK(out.size == doctest::Approx(100.0f).epsilon(0.5));
    }

    TEST_CASE("input maxlifetime: returns init.lifetime") {
        json j = { { "name", "remapvalue" },
                   { "input", "maxlifetime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "linear" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 5.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size          = 0.0f;
        p.init.lifetime = 2.5f;
        auto out = runOpSingle(op, p);
        CHECK(out.size == doctest::Approx(50.0f).epsilon(0.5));
    }
}

// boids speed > maxspeed — line 1951
TEST_SUITE("boids maxspeed clamp deeper") {
    TEST_CASE("boids speed > maxspeed && > 1e-9: clamped") {
        json j = { { "name", "boids" },
                   { "neighborthreshold", 0.0 },
                   { "separationthreshold", 0.0 },
                   { "separationfactor", 0.0 },
                   { "alignmentfactor", 0.0 },
                   { "cohesionfactor", 0.0 },
                   { "maxspeed", 5.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        std::vector<Particle> ps;
        ps.reserve(8);
        Particle a = makeParticle();
        a.position = Eigen::Vector3f(0, 0, 0);
        a.velocity = Eigen::Vector3f(50, 0, 0);
        ps.push_back(a);
        std::vector<ParticleControlpoint> cps_storage;
        auto                              cps = defaultCps(cps_storage);
        ParticleInfo                      info {
                              .particles     = std::span<Particle>(ps),
                              .controlpoints = cps,
                              .time          = 0.0,
                              .time_pass     = 0.016,
        };
        op(info);
        CHECK(ps[0].velocity.norm() == doctest::Approx(5.0f).epsilon(0.05));
    }
}

// turbulence noiseRate — line 1042
TEST_SUITE("turbulence noiseRate threshold") {
    TEST_CASE("noiseRate > 1.0: incoherent path zeroes mask=0 axes") {
        json j = { { "name", "turbulence" },
                   { "speedmin", 100.0 }, { "speedmax", 100.0 },
                   { "phasemin", 0.0 }, { "phasemax", 0.0 },
                   { "timescale", 10.0 }, { "scale", 0.1 },
                   { "mask", "1 1 0" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(1, 1, 0);
        auto out = runOpSingle(op, p, 1.0);
        CHECK(out.position.z() == doctest::Approx(0.0f).epsilon(0.05));
    }
}

// vortex middle zone — line 1090
TEST_SUITE("vortex middle zone t formula") {
    TEST_CASE("vortex middle dist=125: lerp(0.25, 100, 200)≈125 (negative y)") {
        json j = { { "name", "vortex" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" },
                   { "speedinner", 100.0 }, { "speedouter", 200.0 },
                   { "distanceinner", 100.0 }, { "distanceouter", 200.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(125, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        // vortex (not v2): direct = -axis × pos = -(0,125,0)/norm = (0,-1,0).
        CHECK(out.velocity.y() < -100.0f);
        CHECK(out.velocity.y() > -150.0f);
    }
}

// movement drag scale — line 913
TEST_SUITE("movement drag scale") {
    TEST_CASE("drag with dt=2.0: factor = 1-drag*dt") {
        json j = { { "name", "movement" }, { "drag", 0.2 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.velocity = Eigen::Vector3f(100, 0, 0);
        auto out = runOpSingle(op, p, 2.0);
        // factor = 1 - 0.2*2 = 0.6 → 100*0.6 = 60.
        CHECK(out.velocity.x() == doctest::Approx(60.0f).epsilon(0.5));
    }
}

// vortex_v2 ringradius pull — lines 1158, 1161
TEST_SUITE("vortex_v2 ringradius pull deeper") {
    TEST_CASE("ringwidth=0: any deviation pulls (kills `>` ringwidth boundary)") {
        json j = { { "name", "vortex_v2" }, { "controlpoint", 0 },
                   { "axis", "0 0 1" }, { "flags", 0 },
                   { "speedinner", 0.0 }, { "speedouter", 0.0 },
                   { "distanceinner", 1000.0 }, { "distanceouter", 2000.0 },
                   { "ringradius", 100.0 }, { "ringwidth", 0.0 },
                   { "ringpulldistance", 50.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.position = Eigen::Vector3f(101, 0, 0);
        auto out = runOpSingle(op, p, 1.0);
        // |radiusDiff|=1 > 0; sign=-1 (diff=-1); pull -x by 50.
        CHECK(out.velocity.x() == doctest::Approx(-50.0f).epsilon(0.5));
    }
}

// remapvalue cosine boundary — line 1506
TEST_SUITE("remapvalue cosine boundaries") {
    TEST_CASE("cosine xs=0.5 (cos(π)=-1): tx=0 → mapped=outMin") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "cosine" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.5);
        CHECK(out.size == doctest::Approx(0.0f).epsilon(0.5));
    }
}

// remapvalue square boundary — line 1509
TEST_SUITE("remapvalue square boundaries") {
    TEST_CASE("square xs=0.75 (sin=-1<0): tx=0") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "square" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.75);
        CHECK(out.size == doctest::Approx(0.0f).epsilon(0.5));
    }
}

// remapvalue sine boundary
TEST_SUITE("remapvalue sine zero crossings") {
    TEST_CASE("sine xs=0.5 (sin(π)=0): tx=0.5 → mapped=midpoint") {
        json j = { { "name", "remapvalue" },
                   { "input", "particlesystemtime" },
                   { "output", "particlesize" }, { "operation", "set" },
                   { "transformfunction", "sine" }, { "transforminputscale", 1.0 },
                   { "inputrangemin", 0.0 }, { "inputrangemax", 1.0 },
                   { "outputrangemin", 0.0 }, { "outputrangemax", 100.0 } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.size = 0.0f;
        auto out = runOpSingle(op, p, 0.016, 0.5);
        // sin(π)=0; tx=0.5; mapped=50.
        CHECK(out.size == doctest::Approx(50.0f).epsilon(0.5));
    }
}

// inheritvaluefromevent — line 1986
TEST_SUITE("inheritvaluefromevent unknown input") {
    TEST_CASE("input rotation no instance: rotation unchanged") {
        json j = { { "name", "inheritvaluefromevent" },
                   { "input", "rotation" } };
        auto op = WPParticleParser::genParticleOperatorOp(j, emptyOverride());
        Particle p = makeParticle();
        p.rotation = Eigen::Vector3f(0.1f, 0.2f, 0.3f);
        auto out = runOpSingle(op, p);
        CHECK(out.rotation.x() == doctest::Approx(0.1f));
        CHECK(out.rotation.y() == doctest::Approx(0.2f));
        CHECK(out.rotation.z() == doctest::Approx(0.3f));
    }
}

